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,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest/md5'
4
+ require 'set'
5
+
6
+ module RSpecTracer
7
+ # Internal Tracker — see {RSpecTracer} for the user-facing surface.
8
+ # @api private
9
+ module Tracker
10
+ # Observer #5 in the 2.0 tracker pipeline (WholeSuiteInvalidators
11
+ # is #4). Owns the per-run snapshot of environment-variable
12
+ # values that the per-example `tracks: { env: ... }` metadata declares.
13
+ #
14
+ # Watch list is caller-provided - unlike WholeSuiteInvalidators,
15
+ # which hard-codes Gemfile.lock / .ruby-version / .rspec-tracer,
16
+ # env names come from user metadata and are only known once
17
+ # RSpec has discovered every example. The engine unions every
18
+ # example's declared env names and hands the set to
19
+ # `digest_snapshot` at finalize time.
20
+ #
21
+ # Digest algorithm: `Digest::MD5.hexdigest(ENV[name].to_s)`.
22
+ # Missing env var digests the same as empty string - absent is a
23
+ # valid state. ENV is process-stable within a single run (we
24
+ # never mutate it mid-run), so one snapshot per run is enough.
25
+ #
26
+ # Graceful degradation: the observer never raises on malformed
27
+ # input. Non-String env names are coerced via #to_s; nil / empty
28
+ # names are skipped silently.
29
+ class EnvSnapshot
30
+ # ENV source is injectable for testability - specs can pass a
31
+ # Hash double without poking the real process env.
32
+ def initialize(env: ::ENV)
33
+ @env = env
34
+ end
35
+
36
+ # Snapshot the current ENV values for `names`. Returns
37
+ # `Hash[name => md5_hex]`. Idempotent; callers may invoke it
38
+ # repeatedly without side effects.
39
+ def digest_snapshot(names)
40
+ snapshot = {}
41
+ names.each do |raw|
42
+ key = raw.to_s
43
+ next if key.empty?
44
+
45
+ snapshot[key] = Digest::MD5.hexdigest(@env[key].to_s)
46
+ end
47
+ snapshot
48
+ end
49
+
50
+ # Return the set of env names whose digest differs between
51
+ # `previous_snapshot` and the current ENV. Keys in either
52
+ # snapshot participate:
53
+ #
54
+ # - present in both, digests differ => invalidated
55
+ # - present in previous, absent now => invalidated
56
+ # - absent previously, present now => invalidated
57
+ #
58
+ # `previous_snapshot` may be nil (first run, no cache); in that
59
+ # case every currently-tracked key is considered invalidated so
60
+ # the examples declaring them get a mandatory cold re-run on
61
+ # first sighting. `names` scopes the check: only keys in
62
+ # `names` are considered, which means one example adding a new
63
+ # `tracks: env: ...` doesn't cascade-invalidate examples that
64
+ # declared different env keys previously.
65
+ def invalidated_keys(previous_snapshot, names)
66
+ previous = previous_snapshot || {}
67
+ current = digest_snapshot(names)
68
+ invalidated = Set.new
69
+
70
+ current.each do |key, digest|
71
+ invalidated << key if digest != previous[key]
72
+ end
73
+ invalidated
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,153 @@
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
+ # Per-example status + metadata registry. The Filter consults the
10
+ # registry to decide which examples always re-run (failed / flaky
11
+ # / pending / interrupted); the registry also owns duplicate
12
+ # detection via RSpec's identity-hash surface (the same hash 1.x
13
+ # uses for `fail_on_duplicates`).
14
+ #
15
+ # This module owns the data structure only. `update_status` is
16
+ # called by the RSpec integration hooks on example-finished events
17
+ # and by signal handlers on interruption; `Tracker.setup` passes
18
+ # the registry instance through. The registry itself has no
19
+ # opinion about where status comes from.
20
+ #
21
+ # Statuses
22
+ # --------
23
+ # :passed - completed cleanly
24
+ # :failed - example assertion failed
25
+ # :pending - RSpec `pending`
26
+ # :interrupted - RSpec was killed mid-example (SIGINT / SIGTERM)
27
+ # :flaky - passed this run but previously failed
28
+ # (detected via retry semantics)
29
+ # :skipped - skipped via `skip` or `:skip` metadata; tracked
30
+ # for coverage attribution but NOT auto-re-run
31
+ #
32
+ # `always_re_run_ids` returns the union of {failed, flaky,
33
+ # pending, interrupted} - the 1.x invariant that examples with
34
+ # those statuses run on every subsequent suite regardless of
35
+ # whether their input files changed. `:skipped` is deliberately
36
+ # excluded (matches 1.x `skipped_examples.json` which is written
37
+ # but not added to the re-run set).
38
+ class ExampleRegistry
39
+ # Internal constant.
40
+ # @api private
41
+ STATUSES = %i[passed failed pending interrupted flaky skipped].freeze
42
+ # Internal constant.
43
+ # @api private
44
+ ALWAYS_RE_RUN_STATUSES = %i[failed flaky pending interrupted].freeze
45
+
46
+ # Internal method on the tracer pipeline.
47
+ # @api private
48
+ def initialize
49
+ @examples = {}
50
+ @identity_index = {}
51
+ @duplicates = {}
52
+ end
53
+
54
+ # Registers an example id with optional metadata (opaque Hash
55
+ # passed through to Snapshot.all_examples) and optional
56
+ # identity_hash for duplicate detection. First call wins for
57
+ # the identity_hash binding; subsequent calls with the same
58
+ # identity_hash but a different example_id accumulate into
59
+ # `@duplicates` and do not re-bind.
60
+ def register(example_id, metadata: {}, identity_hash: nil)
61
+ @examples[example_id] ||= { metadata: metadata.dup, status: nil }
62
+ track_identity(example_id, identity_hash) if identity_hash
63
+ self
64
+ end
65
+
66
+ # Internal method on the tracer pipeline.
67
+ # @api private
68
+ def update_status(example_id, status)
69
+ raise ArgumentError, "unknown status: #{status.inspect}" unless STATUSES.include?(status)
70
+ raise ArgumentError, "example not registered: #{example_id.inspect}" unless @examples.key?(example_id)
71
+
72
+ @examples[example_id][:status] = status
73
+ self
74
+ end
75
+
76
+ # Internal method on the tracer pipeline.
77
+ # @api private
78
+ def status_of(example_id)
79
+ @examples[example_id]&.dig(:status)
80
+ end
81
+
82
+ # Internal method on the tracer pipeline.
83
+ # @api private
84
+ def metadata_of(example_id)
85
+ entry = @examples[example_id]
86
+ return nil if entry.nil?
87
+
88
+ entry[:metadata].dup
89
+ end
90
+
91
+ # Internal method on the tracer pipeline.
92
+ # @api private
93
+ def registered?(example_id)
94
+ @examples.key?(example_id)
95
+ end
96
+
97
+ # Internal method on the tracer pipeline.
98
+ # @api private
99
+ def all_example_ids
100
+ @examples.keys.to_set
101
+ end
102
+
103
+ # Internal method on the tracer pipeline.
104
+ # @api private
105
+ def size
106
+ @examples.size
107
+ end
108
+
109
+ # Internal method on the tracer pipeline.
110
+ # @api private
111
+ def ids_with_status(status)
112
+ @examples.each_with_object(Set.new) do |(id, entry), acc|
113
+ acc << id if entry[:status] == status
114
+ end
115
+ end
116
+
117
+ # Internal method on the tracer pipeline.
118
+ # @api private
119
+ def always_re_run_ids
120
+ @examples.each_with_object(Set.new) do |(id, entry), acc|
121
+ acc << id if ALWAYS_RE_RUN_STATUSES.include?(entry[:status])
122
+ end
123
+ end
124
+
125
+ # Hash[identity_hash => Array<example_id>] of every collision
126
+ # observed. The array always has >= 2 entries (the first
127
+ # registrant plus every colliding follower).
128
+ def duplicates
129
+ @duplicates.transform_values(&:dup)
130
+ end
131
+
132
+ # Internal method on the tracer pipeline.
133
+ # @api private
134
+ def duplicate?(identity_hash)
135
+ @duplicates.key?(identity_hash)
136
+ end
137
+
138
+ private
139
+
140
+ # Internal method on the tracer pipeline.
141
+ # @api private
142
+ def track_identity(example_id, identity_hash)
143
+ existing = @identity_index[identity_hash]
144
+ if existing.nil?
145
+ @identity_index[identity_hash] = example_id
146
+ elsif existing != example_id
147
+ bucket = (@duplicates[identity_hash] ||= [existing])
148
+ bucket << example_id unless bucket.include?(example_id)
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module RSpecTracer
6
+ # Internal Tracker — see {RSpecTracer} for the user-facing surface.
7
+ # @api private
8
+ module Tracker
9
+ # Process-wide SHA256 file-digest cache. Keyed on absolute path
10
+ # with [mtime_ns, size] as the freshness check — when the file's
11
+ # stat timestamps + size match the cached values, the prior
12
+ # digest is reused; otherwise the digest is recomputed and the
13
+ # cache entry refreshed.
14
+ #
15
+ # The cache is populated lazily and never evicted within a
16
+ # process. Tracer runs are bounded (one rspec invocation per
17
+ # process), so unbounded growth is bounded by the project's
18
+ # dependency-graph size (typically << 10k unique files).
19
+ #
20
+ # SystemCallError on stat / digest is treated as "file gone /
21
+ # unreadable" and returns nil — same graceful-degradation
22
+ # contract every call site already implemented locally before
23
+ # this module centralized them.
24
+ #
25
+ # Thread-safety: rspec example execution is single-threaded
26
+ # (cold-subprocess contract); the cache uses a plain Hash without
27
+ # locking. Multi-threaded callers are unsupported (consistent
28
+ # with the rest of the tracer's threading model).
29
+ module FileDigest
30
+ class << self
31
+ # Returns the SHA256 hex digest of `path`, or nil when the
32
+ # file can't be stat'd (missing / permission denied / racey
33
+ # delete). Subsequent calls for the same path with unchanged
34
+ # stat skip the digest computation entirely.
35
+ def compute(path)
36
+ stat = File.stat(path)
37
+ key = (stat.mtime.to_i * 1_000_000_000) + stat.mtime.nsec
38
+ size = stat.size
39
+ cache = (@cache ||= {})
40
+ cached = cache[path]
41
+ return cached[2] if cached && cached[0] == key && cached[1] == size
42
+
43
+ digest = Digest::SHA256.file(path).hexdigest
44
+ cache[path] = [key, size, digest]
45
+ digest
46
+ rescue SystemCallError
47
+ nil
48
+ end
49
+
50
+ # Drop the cache. Tests that mutate file content in-place at
51
+ # nanosecond granularity (and so might collide on the
52
+ # mtime+size key) should call this between scenarios. Normal
53
+ # tracer runs never need it — the cache lives for one
54
+ # rspec invocation only.
55
+ def reset!
56
+ @cache = {}
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,127 @@
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
+ # Pure function that computes the filter result for a suite run:
10
+ # given the previous dependency graph, the current change set,
11
+ # the example registry, the whole-suite invalidation signal, and
12
+ # the full list of example ids that exist this run, return the
13
+ # Hash of {example_id => reason} for every example that must run.
14
+ #
15
+ # Stateless - every input is passed explicitly so the function is
16
+ # trivial to unit-test, property-test, and use as a behavior-
17
+ # parity target against 1.x's runner.rb.
18
+ #
19
+ # Precedence (first-match wins, highest to lowest):
20
+ #
21
+ # 1. :whole_suite_invalidator - any watched file changed
22
+ # (Gemfile.lock, .ruby-version, .rspec-tracer, gem version)
23
+ # => every id in all_example_ids runs, no further checks.
24
+ # 2. :interrupted - example was killed mid-run
25
+ # on the previous run; always re-run.
26
+ # 3. :flaky_example - example was marked flaky; always
27
+ # re-run to re-observe.
28
+ # 4. :failed_example - previous failure; always re-run.
29
+ # 5. :pending_example - previous pending; always re-run.
30
+ # 6. :no_cache - example exists this run but
31
+ # isn't in the graph (new example, never observed).
32
+ # 7. :files_changed - example's cached dependency
33
+ # set intersects the change_set.
34
+ #
35
+ # :skipped is deliberately not a reason - 1.x tracks skipped
36
+ # examples for coverage attribution but does not re-run them.
37
+ # ExampleRegistry#always_re_run_ids excludes :skipped; this
38
+ # function mirrors that by iterating only the four re-run
39
+ # statuses.
40
+ module Filter
41
+ # Internal constant.
42
+ # @api private
43
+ REASONS = %i[
44
+ whole_suite_invalidator
45
+ interrupted
46
+ flaky_example
47
+ failed_example
48
+ pending_example
49
+ no_cache
50
+ files_changed
51
+ ].freeze
52
+
53
+ # Map from registry status to filter reason. Keyed in
54
+ # precedence order so iteration preserves the precedence when
55
+ # first-match wins in #assign_once.
56
+ STATUS_TO_REASON = {
57
+ interrupted: :interrupted,
58
+ flaky: :flaky_example,
59
+ failed: :failed_example,
60
+ pending: :pending_example
61
+ }.freeze
62
+
63
+ # Internal helper for the tracer pipeline.
64
+ # @api private
65
+ def self.select(graph:, change_set:, registry:, whole_suite_invalidated:, all_example_ids:)
66
+ ids = all_example_ids.to_set
67
+ return whole_suite_result(ids) if whole_suite_invalidated
68
+
69
+ result = {}
70
+ add_always_re_run(result, registry, ids)
71
+ add_new_examples(result, graph, ids)
72
+ add_files_changed(result, graph, change_set, ids)
73
+ result
74
+ end
75
+
76
+ # Convenience: Set<example_id> view of a select() result.
77
+ def self.to_run_set(result)
78
+ result.keys.to_set
79
+ end
80
+
81
+ # Internal helper for the tracer pipeline.
82
+ # @api private
83
+ def self.whole_suite_result(ids)
84
+ ids.to_h { |id| [id, :whole_suite_invalidator] }
85
+ end
86
+
87
+ # Internal helper for the tracer pipeline.
88
+ # @api private
89
+ def self.add_always_re_run(result, registry, ids)
90
+ STATUS_TO_REASON.each do |status, reason|
91
+ registry.ids_with_status(status).each do |id|
92
+ next unless ids.include?(id)
93
+
94
+ assign_once(result, id, reason)
95
+ end
96
+ end
97
+ end
98
+
99
+ # Internal helper for the tracer pipeline.
100
+ # @api private
101
+ def self.add_new_examples(result, graph, ids)
102
+ known = graph.example_ids.to_set
103
+ ids.each do |id|
104
+ next if known.include?(id)
105
+
106
+ assign_once(result, id, :no_cache)
107
+ end
108
+ end
109
+
110
+ # Internal helper for the tracer pipeline.
111
+ # @api private
112
+ def self.add_files_changed(result, graph, change_set, ids)
113
+ graph.examples_depending_on(change_set).each do |id|
114
+ next unless ids.include?(id)
115
+
116
+ assign_once(result, id, :files_changed)
117
+ end
118
+ end
119
+
120
+ # Internal helper for the tracer pipeline.
121
+ # @api private
122
+ def self.assign_once(result, id, reason)
123
+ result[id] ||= reason
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,99 @@
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
+ # Closed taxonomy of input sources. The kinds correspond to the
10
+ # observation surface: `:ruby` (Coverage-observed source), `:data`
11
+ # (I/O hooks), `:declared` / `:lockfile` (declared globs), `:env`
12
+ # (env_snapshot), `:notification` (Rails notifications),
13
+ # `:template` / `:schema` (Rails subscribers). Adding a new kind
14
+ # is a one-line change here plus a test; shrinking the set is a
15
+ # schema_version bump.
16
+ ALLOWED_INPUT_KINDS = %i[
17
+ ruby template data schema lockfile declared env notification
18
+ ].to_set.freeze
19
+
20
+ # Value object representing a single input to a test. A test is a
21
+ # pure function of its inputs (see ARCHITECTURE.md); every input
22
+ # the tracker observes becomes one Input.
23
+ #
24
+ # Construct via Input.for_file - it expands the absolute path,
25
+ # validates the kind, and precomputes the stable identity string.
26
+ # The returned struct is frozen.
27
+ #
28
+ # Equality, hash, and eql? key on :identity only - two Inputs with
29
+ # the same identity but different digests are considered the same
30
+ # input at different points in time. Freshness lives on :digest
31
+ # and is queried via #stale?.
32
+ #
33
+ # Digest algorithm is caller-chosen (the observer owns content
34
+ # hashing); 2.0's default is SHA256 hex (see CoverageAdapter).
35
+ # Changing the algorithm is a storage schema_version bump.
36
+ #
37
+ # Methods are defined on the reopened class body (not the
38
+ # Struct.new block) so mutant can introspect them via
39
+ # Method#source_location - block-scoped defs live on an anonymous
40
+ # singleton and mutant reports Subjects: 0 for them.
41
+ Input = Struct.new(:path, :kind, :digest, :identity, keyword_init: true)
42
+
43
+ # Internal Input — see {RSpecTracer} for the user-facing surface.
44
+ # @api private
45
+ class Input
46
+ # Internal helper for the tracer pipeline.
47
+ # @api private
48
+ def self.for_file(path:, kind:, digest:, root:)
49
+ unless ALLOWED_INPUT_KINDS.include?(kind)
50
+ raise ArgumentError,
51
+ "invalid Input kind: #{kind.inspect}; " \
52
+ "allowed: #{ALLOWED_INPUT_KINDS.to_a.inspect}"
53
+ end
54
+
55
+ abs_path = File.expand_path(path)
56
+ identity = "#{kind}:#{relative_path(abs_path, root)}"
57
+
58
+ new(path: abs_path, kind: kind, digest: digest, identity: identity).freeze
59
+ end
60
+
61
+ # Strip `root/` prefix from an absolute path. When the path
62
+ # escapes the root (absolute symlink target, vendored gem under a
63
+ # different tree, etc.) we fall back to the full absolute path so
64
+ # identity stays unique - the deterministic rule is "same path
65
+ # under same root => same identity", nothing stronger.
66
+ def self.relative_path(abs_path, root)
67
+ root_abs = File.expand_path(root)
68
+ prefix = "#{root_abs}/"
69
+
70
+ return abs_path unless abs_path.start_with?(prefix)
71
+
72
+ abs_path[prefix.length..]
73
+ end
74
+
75
+ # `!=` handles every case correctly: nil-vs-nil is NOT stale
76
+ # (absent stayed absent), present-vs-nil and nil-vs-present are
77
+ # stale (file appeared/disappeared), and digest-vs-digest is
78
+ # stale iff the content changed.
79
+ def stale?(current_digest)
80
+ current_digest != digest
81
+ end
82
+
83
+ # Inputs are value-typed and not meant to be subclassed;
84
+ # `instance_of?` is precise where `is_a?` would let a subclass
85
+ # with matching identity compare equal.
86
+ def ==(other)
87
+ other.instance_of?(self.class) && identity == other.identity
88
+ end
89
+
90
+ alias eql? ==
91
+
92
+ # Internal method on the tracer pipeline.
93
+ # @api private
94
+ def hash
95
+ identity.hash
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,55 @@
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
+ # Internal IOHooks — see {RSpecTracer} for the user-facing surface.
8
+ # @api private
9
+ module IOHooks
10
+ # Prepended onto File.singleton_class. Each method records the
11
+ # path (IOHooks.record fast-rejects outside a bucketed example
12
+ # and swallows any error) then forwards every argument + block
13
+ # to super via `(...)`.
14
+ #
15
+ # The `if Thread.current[BUCKET_KEY]` guard is the hot-path
16
+ # cut: FileReads sits in every File.singleton_class read on
17
+ # the entire process for the life of the run, even between
18
+ # examples and during boot. Without the guard each File.read
19
+ # paid 2 method-dispatch frames + IOHooks._record's
20
+ # `@root_prefix` nil-check before the inner bucket check could
21
+ # bail. With the guard, the bucket check happens at FileReads
22
+ # so the reject path skips IOHooks.record entirely. Cuts the
23
+ # M2 Max reject overhead from ~530 ns/call to ~300 ns/call.
24
+ module FileReads
25
+ # Internal method on the tracer pipeline.
26
+ # @api private
27
+ def read(path, ...)
28
+ IOHooks.record(path) if Thread.current[BUCKET_KEY]
29
+ super
30
+ end
31
+
32
+ # Internal method on the tracer pipeline.
33
+ # @api private
34
+ def binread(path, ...)
35
+ IOHooks.record(path) if Thread.current[BUCKET_KEY]
36
+ super
37
+ end
38
+
39
+ # Internal method on the tracer pipeline.
40
+ # @api private
41
+ def readlines(path, ...)
42
+ IOHooks.record(path) if Thread.current[BUCKET_KEY]
43
+ super
44
+ end
45
+
46
+ # Internal method on the tracer pipeline.
47
+ # @api private
48
+ def open(path, ...)
49
+ IOHooks.record(path) if Thread.current[BUCKET_KEY]
50
+ super
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,24 @@
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
+ # Internal IOHooks — see {RSpecTracer} for the user-facing surface.
8
+ # @api private
9
+ module IOHooks
10
+ # Prepended onto IO.singleton_class. IO.read is the only method
11
+ # the brief asks for here - File.read covers the bulk of the
12
+ # use cases; IO.read exists mostly for compatibility with older
13
+ # code paths and third-party libraries that reach through IO.
14
+ module IOReads
15
+ # Internal method on the tracer pipeline.
16
+ # @api private
17
+ def read(path, ...)
18
+ IOHooks.record(path) if Thread.current[BUCKET_KEY]
19
+ super
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,23 @@
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
+ # Internal IOHooks — see {RSpecTracer} for the user-facing surface.
8
+ # @api private
9
+ module IOHooks
10
+ # Prepended onto JSON.singleton_class. JSON.load_file is the
11
+ # user-level file-reading entry point; JSON.parse takes a
12
+ # string and is not hooked here.
13
+ module JSONReads
14
+ # Internal method on the tracer pipeline.
15
+ # @api private
16
+ def load_file(path, ...)
17
+ IOHooks.record(path) if Thread.current[BUCKET_KEY]
18
+ super
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,26 @@
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
+ # Internal IOHooks — see {RSpecTracer} for the user-facing surface.
8
+ # @api private
9
+ module IOHooks
10
+ # Prepended onto both Kernel and Kernel.singleton_class so both
11
+ # implicit `load 'x.rb'` (method-lookup via Object's ancestor
12
+ # chain) and explicit `Kernel.load('x.rb')` (singleton dispatch)
13
+ # fire the hook. Records as :ruby - CoverageAdapter also sees
14
+ # these files through the Coverage module's load-path
15
+ # instrumentation; the example registry dedupes the overlap.
16
+ module KernelReads
17
+ # Internal method on the tracer pipeline.
18
+ # @api private
19
+ def load(path, ...)
20
+ IOHooks.record_ruby_load(path) if Thread.current[BUCKET_KEY]
21
+ super
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,38 @@
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
+ # Internal IOHooks — see {RSpecTracer} for the user-facing surface.
8
+ # @api private
9
+ module IOHooks
10
+ # Prepended onto YAML.singleton_class (Psych). Hooks the three
11
+ # load_file-family entry points; Psych's internals eventually
12
+ # call File.read but YAML.load_file is the user-level API that
13
+ # .rspec-tracer filters target.
14
+ module YAMLReads
15
+ # Internal method on the tracer pipeline.
16
+ # @api private
17
+ def load_file(path, ...)
18
+ IOHooks.record(path) if Thread.current[BUCKET_KEY]
19
+ super
20
+ end
21
+
22
+ # Internal method on the tracer pipeline.
23
+ # @api private
24
+ def safe_load_file(path, ...)
25
+ IOHooks.record(path) if Thread.current[BUCKET_KEY]
26
+ super
27
+ end
28
+
29
+ # Internal method on the tracer pipeline.
30
+ # @api private
31
+ def unsafe_load_file(path, ...)
32
+ IOHooks.record(path) if Thread.current[BUCKET_KEY]
33
+ super
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end