rspec-tracer 1.2.3 → 2.0.0.pre.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +384 -67
  3. data/README.md +454 -429
  4. data/bin/rspec-tracer +15 -0
  5. data/lib/rspec_tracer/cache/Rakefile +43 -0
  6. data/lib/rspec_tracer/cli/cache_clear.rb +111 -0
  7. data/lib/rspec_tracer/cli/cache_info.rb +104 -0
  8. data/lib/rspec_tracer/cli/doctor.rb +284 -0
  9. data/lib/rspec_tracer/cli/explain.rb +158 -0
  10. data/lib/rspec_tracer/cli/report_open.rb +82 -0
  11. data/lib/rspec_tracer/cli.rb +116 -0
  12. data/lib/rspec_tracer/configuration.rb +1196 -3
  13. data/lib/rspec_tracer/engine.rb +1168 -0
  14. data/lib/rspec_tracer/example.rb +141 -11
  15. data/lib/rspec_tracer/filter.rb +35 -0
  16. data/lib/rspec_tracer/line_stub.rb +61 -0
  17. data/lib/rspec_tracer/load_config.rb +2 -2
  18. data/lib/rspec_tracer/logger.rb +15 -0
  19. data/lib/rspec_tracer/rails/README.md +78 -0
  20. data/lib/rspec_tracer/rails/i18n_tracking.rb +137 -0
  21. data/lib/rspec_tracer/rails/notifications.rb +263 -0
  22. data/lib/rspec_tracer/rails/preset.rb +94 -0
  23. data/lib/rspec_tracer/rails/railtie.rb +22 -0
  24. data/lib/rspec_tracer/rails.rb +15 -0
  25. data/lib/rspec_tracer/remote_cache/README.md +140 -0
  26. data/lib/rspec_tracer/remote_cache/Rakefile +35 -11
  27. data/lib/rspec_tracer/remote_cache/archive.rb +137 -0
  28. data/lib/rspec_tracer/remote_cache/backend.rb +73 -0
  29. data/lib/rspec_tracer/remote_cache/git_ancestry.rb +241 -0
  30. data/lib/rspec_tracer/remote_cache/local_fs_backend.rb +439 -0
  31. data/lib/rspec_tracer/remote_cache/redis_backend.rb +554 -0
  32. data/lib/rspec_tracer/remote_cache/s3_backend.rb +712 -0
  33. data/lib/rspec_tracer/remote_cache/user_tasks.rb +436 -0
  34. data/lib/rspec_tracer/remote_cache/validator.rb +40 -62
  35. data/lib/rspec_tracer/remote_cache.rb +22 -0
  36. data/lib/rspec_tracer/reporters/README.md +103 -0
  37. data/lib/rspec_tracer/reporters/base.rb +87 -0
  38. data/lib/rspec_tracer/reporters/coverage_json_reporter.rb +338 -0
  39. data/lib/rspec_tracer/reporters/html/.gitignore +19 -0
  40. data/lib/rspec_tracer/reporters/html/.prettierignore +4 -0
  41. data/lib/rspec_tracer/reporters/html/.prettierrc.json +9 -0
  42. data/lib/rspec_tracer/reporters/html/README.md +80 -0
  43. data/lib/rspec_tracer/reporters/html/dist/assets/index.css +2 -0
  44. data/lib/rspec_tracer/reporters/html/dist/assets/index.js +1 -0
  45. data/lib/rspec_tracer/reporters/html/dist/index.html +24 -0
  46. data/lib/rspec_tracer/reporters/html/eslint.config.js +62 -0
  47. data/lib/rspec_tracer/reporters/html/package-lock.json +4941 -0
  48. data/lib/rspec_tracer/reporters/html/package.json +29 -0
  49. data/lib/rspec_tracer/reporters/html/src/app.jsx +130 -0
  50. data/lib/rspec_tracer/reporters/html/src/components/AllExamples.jsx +86 -0
  51. data/lib/rspec_tracer/reporters/html/src/components/DuplicateExamples.jsx +68 -0
  52. data/lib/rspec_tracer/reporters/html/src/components/ExamplesDependency.jsx +78 -0
  53. data/lib/rspec_tracer/reporters/html/src/components/FilesDependency.jsx +72 -0
  54. data/lib/rspec_tracer/reporters/html/src/components/FlakyExamples.jsx +42 -0
  55. data/lib/rspec_tracer/reporters/html/src/components/ReportTable.jsx +131 -0
  56. data/lib/rspec_tracer/reporters/html/src/components/SearchBar.jsx +19 -0
  57. data/lib/rspec_tracer/reporters/html/src/index.html +23 -0
  58. data/lib/rspec_tracer/reporters/html/src/main.jsx +37 -0
  59. data/lib/rspec_tracer/reporters/html/src/styles.css +434 -0
  60. data/lib/rspec_tracer/reporters/html/vite.config.js +42 -0
  61. data/lib/rspec_tracer/reporters/html_reporter.rb +266 -0
  62. data/lib/rspec_tracer/reporters/json_reporter.rb +88 -0
  63. data/lib/rspec_tracer/reporters/payload_builder.rb +235 -0
  64. data/lib/rspec_tracer/reporters/registry.rb +120 -0
  65. data/lib/rspec_tracer/reporters/terminal_reporter.rb +264 -0
  66. data/lib/rspec_tracer/rspec/README.md +73 -0
  67. data/lib/rspec_tracer/rspec/installation.rb +97 -0
  68. data/lib/rspec_tracer/rspec/metadata.rb +96 -0
  69. data/lib/rspec_tracer/rspec/parallel_tests.rb +459 -0
  70. data/lib/rspec_tracer/rspec/reporter_hook.rb +84 -0
  71. data/lib/rspec_tracer/rspec/runner_hook.rb +239 -0
  72. data/lib/rspec_tracer/source_file.rb +24 -7
  73. data/lib/rspec_tracer/storage/README.md +35 -0
  74. data/lib/rspec_tracer/storage/backend.rb +130 -0
  75. data/lib/rspec_tracer/storage/json_backend.rb +884 -0
  76. data/lib/rspec_tracer/storage/lazy_snapshot.rb +65 -0
  77. data/lib/rspec_tracer/storage/schema.rb +50 -0
  78. data/lib/rspec_tracer/storage/serializer/json.rb +41 -0
  79. data/lib/rspec_tracer/storage/serializer/msgpack.rb +167 -0
  80. data/lib/rspec_tracer/storage/snapshot.rb +141 -0
  81. data/lib/rspec_tracer/storage/sqlite_backend.rb +693 -0
  82. data/lib/rspec_tracer/time_formatter.rb +37 -18
  83. data/lib/rspec_tracer/tracker/README.md +36 -0
  84. data/lib/rspec_tracer/tracker/coverage_adapter.rb +174 -0
  85. data/lib/rspec_tracer/tracker/declared_globs.rb +100 -0
  86. data/lib/rspec_tracer/tracker/dependency_graph.rb +134 -0
  87. data/lib/rspec_tracer/tracker/env_matcher.rb +127 -0
  88. data/lib/rspec_tracer/tracker/env_snapshot.rb +77 -0
  89. data/lib/rspec_tracer/tracker/example_registry.rb +153 -0
  90. data/lib/rspec_tracer/tracker/file_digest.rb +61 -0
  91. data/lib/rspec_tracer/tracker/filter.rb +127 -0
  92. data/lib/rspec_tracer/tracker/input.rb +99 -0
  93. data/lib/rspec_tracer/tracker/io_hooks/file.rb +55 -0
  94. data/lib/rspec_tracer/tracker/io_hooks/io.rb +24 -0
  95. data/lib/rspec_tracer/tracker/io_hooks/json.rb +23 -0
  96. data/lib/rspec_tracer/tracker/io_hooks/kernel.rb +26 -0
  97. data/lib/rspec_tracer/tracker/io_hooks/yaml.rb +38 -0
  98. data/lib/rspec_tracer/tracker/io_hooks.rb +195 -0
  99. data/lib/rspec_tracer/tracker/loaded_files_tracker.rb +295 -0
  100. data/lib/rspec_tracer/tracker/new_file_detector.rb +62 -0
  101. data/lib/rspec_tracer/tracker/whole_suite_invalidators.rb +96 -0
  102. data/lib/rspec_tracer/version.rb +4 -1
  103. data/lib/rspec_tracer.rb +231 -491
  104. metadata +94 -43
  105. data/lib/rspec_tracer/cache.rb +0 -207
  106. data/lib/rspec_tracer/coverage_merger.rb +0 -42
  107. data/lib/rspec_tracer/coverage_reporter.rb +0 -187
  108. data/lib/rspec_tracer/coverage_writer.rb +0 -58
  109. data/lib/rspec_tracer/html_reporter/Rakefile +0 -18
  110. data/lib/rspec_tracer/html_reporter/assets/javascripts/application.js +0 -56
  111. data/lib/rspec_tracer/html_reporter/assets/javascripts/libraries/jquery.js +0 -10881
  112. data/lib/rspec_tracer/html_reporter/assets/javascripts/plugins/datatables.js +0 -15381
  113. data/lib/rspec_tracer/html_reporter/assets/stylesheets/application.css +0 -196
  114. data/lib/rspec_tracer/html_reporter/assets/stylesheets/plugins/datatables.css +0 -459
  115. data/lib/rspec_tracer/html_reporter/assets/stylesheets/plugins/jquery-ui.css +0 -436
  116. data/lib/rspec_tracer/html_reporter/assets/stylesheets/print.css +0 -92
  117. data/lib/rspec_tracer/html_reporter/assets/stylesheets/reset.css +0 -265
  118. data/lib/rspec_tracer/html_reporter/public/application.css +0 -5
  119. data/lib/rspec_tracer/html_reporter/public/application.js +0 -6
  120. data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_asc.png +0 -0
  121. data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_asc_disabled.png +0 -0
  122. data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_both.png +0 -0
  123. data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_desc.png +0 -0
  124. data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_desc_disabled.png +0 -0
  125. data/lib/rspec_tracer/html_reporter/public/favicon.png +0 -0
  126. data/lib/rspec_tracer/html_reporter/public/loading.gif +0 -0
  127. data/lib/rspec_tracer/html_reporter/reporter.rb +0 -242
  128. data/lib/rspec_tracer/html_reporter/views/duplicate_examples.erb +0 -34
  129. data/lib/rspec_tracer/html_reporter/views/examples.erb +0 -58
  130. data/lib/rspec_tracer/html_reporter/views/examples_dependency.erb +0 -36
  131. data/lib/rspec_tracer/html_reporter/views/files_dependency.erb +0 -36
  132. data/lib/rspec_tracer/html_reporter/views/flaky_examples.erb +0 -38
  133. data/lib/rspec_tracer/html_reporter/views/layout.erb +0 -38
  134. data/lib/rspec_tracer/remote_cache/aws.rb +0 -176
  135. data/lib/rspec_tracer/remote_cache/cache.rb +0 -75
  136. data/lib/rspec_tracer/remote_cache/repo.rb +0 -210
  137. data/lib/rspec_tracer/report_generator.rb +0 -158
  138. data/lib/rspec_tracer/report_merger.rb +0 -68
  139. data/lib/rspec_tracer/report_writer.rb +0 -141
  140. data/lib/rspec_tracer/reporter.rb +0 -204
  141. data/lib/rspec_tracer/rspec_reporter.rb +0 -41
  142. data/lib/rspec_tracer/rspec_runner.rb +0 -56
  143. data/lib/rspec_tracer/ruby_coverage.rb +0 -9
  144. data/lib/rspec_tracer/runner.rb +0 -278
@@ -1,22 +1,147 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RSpecTracer
4
+ # Builds the identity-hash payload (`:example_id`-keyed Hash) that
5
+ # RSpec::RunnerHook attaches to every example pre-run.
6
+ #
7
+ # == Identity stability contract
8
+ #
9
+ # `example_id` is the MD5 of a stable subset of the payload:
10
+ # `example_group` (the describe block's *description* string),
11
+ # `description`, `full_description`, `shared_group` (inclusion
12
+ # locations with the trailing line number stripped), and
13
+ # `file_name`. The contract, in one line: *rename = new identity;
14
+ # restructure = same identity.*
15
+ #
16
+ # Identity is PRESERVED when:
17
+ # - blank lines or comments are added/removed around the example
18
+ # - examples are reordered within a describe block (named
19
+ # examples only - see "Unnamed examples" below)
20
+ # - a sibling describe / example in the same file is renamed
21
+ # - metadata changes (`skip:`, tags, `tracks: { ... }`) - the
22
+ # spec-file digest still triggers the re-run and status history
23
+ # is kept
24
+ # - the example body or its hooks (`before`, `let`) are edited -
25
+ # again, the file digest triggers the re-run
26
+ #
27
+ # Identity CHANGES (one cold "No cache" run) when:
28
+ # - the file is renamed or moved
29
+ # - the `describe` / `it` / shared-example name is changed
30
+ # - the example moves to a different describe block
31
+ #
32
+ # == Unnamed examples (`it { }`, `specify { }`, `example { }`)
33
+ #
34
+ # An example with no description string has no stable name to hash,
35
+ # and identity must be computed pre-run (for the filter decision),
36
+ # before RSpec generates a matcher-derived description. The only
37
+ # line-independent signal RSpec exposes pre-run is position, so an
38
+ # unnamed example's identity is derived from its ordinal among the
39
+ # *unnamed* examples of its group. (RSpec's `description` for an
40
+ # unnamed example is the line-bearing `"example at <path>:<line>"`
41
+ # fallback, which would otherwise leak the line number straight
42
+ # back into the digest - issue #210.)
43
+ #
44
+ # For unnamed examples the contract above is amended: identity is
45
+ # still PRESERVED across blank-line / comment edits, sibling
46
+ # renames, and adding or removing *named* siblings - but it CHANGES
47
+ # when the unnamed examples are reordered, or one is inserted or
48
+ # removed ahead of it. Give an example an explicit description
49
+ # (`it 'does X' do`) for a fully reorder-stable identity.
50
+ #
51
+ # `line_number` / `rerun_file_name` / `rerun_line_number` stay in
52
+ # the returned Hash for the reporter + `explain` location columns,
53
+ # but are DELIBERATELY EXCLUDED from the digest - a no-op edit that
54
+ # shifts line numbers must not invalidate the cache. `example_group`
55
+ # uses `example_group.description` (the user's string) rather than
56
+ # `example_group.name`: RSpec's generated class name carries a
57
+ # load-order-dependent `_2` / `_3` suffix when two files share a
58
+ # describe name, which would otherwise flip the id across runs.
59
+ #
60
+ # Helpers are `def self.x` + `private_class_method` so mutant
61
+ # attributes mutations through the singleton call path.
4
62
  module Example
5
- module_function
63
+ # Identity-keyed cache of `<example_group> => Array<unnamed sibling>`
64
+ # populated lazily by `unnamed_description`. A group with N unnamed
65
+ # examples computes the sibling list once per group rather than N
66
+ # times. Memory is bounded by the live group set (RSpec retains
67
+ # those for the run anyway).
68
+ @unnamed_siblings_cache = {}.compare_by_identity
6
69
 
7
- def from(example)
8
- data = {
9
- example_group: example.example_group.name,
70
+ # Builds the identity payload for one RSpec example. The MD5 is
71
+ # taken over the stability-contract subset (see `digest_identity`
72
+ # for the named-vs-unnamed split); `line_number` / `rerun_*` ride
73
+ # along in the returned Hash for the reporters but never enter the
74
+ # digest. See the module comment for the full stability contract.
75
+ # @api private
76
+ def self.from(example)
77
+ location = example_location(example)
78
+ identity = {
79
+ example_group: example.example_group.description,
10
80
  description: example.description,
11
81
  full_description: example.full_description,
12
82
  shared_group: example.metadata[:shared_group_inclusion_backtrace]
13
- .map(&:formatted_inclusion_location)
14
- }.merge(example_location(example))
83
+ .map { |frame| frame.formatted_inclusion_location.sub(/:\d+\z/, '') },
84
+ file_name: location[:file_name]
85
+ }
86
+
87
+ identity
88
+ .merge(location)
89
+ .merge(example_id: Digest::MD5.hexdigest(digest_identity(example, identity).to_json))
90
+ end
91
+
92
+ # The Hash actually fed to the MD5. For a named example this is
93
+ # `identity` unchanged - byte-identical to the pre-#210 digest.
94
+ # For an unnamed example (`it { }` / `specify { }` / `example { }`)
95
+ # RSpec's `description` is the line-bearing location fallback
96
+ # `"example at <path>:<line>"`, so `description` is swapped for a
97
+ # line-independent positional discriminator before hashing. The
98
+ # returned/stored payload still carries RSpec's `description` /
99
+ # `full_description` untouched - only the digest input differs.
100
+ # @api private
101
+ def self.digest_identity(example, identity)
102
+ return identity unless unnamed?(example)
103
+
104
+ identity.merge(description: unnamed_description(example))
105
+ end
106
+
107
+ # True when the example has no explicit description string. RSpec's
108
+ # raw `metadata[:description]` is `""` for `it { }` / `specify { }`
109
+ # / `example { }`; the `description` *method* would instead return
110
+ # the line-bearing `"example at <path>:<line>"` fallback, so the
111
+ # raw metadata value is what cleanly tells named from unnamed.
112
+ # @api private
113
+ def self.unnamed?(example)
114
+ example.metadata[:description].to_s.strip.empty?
115
+ end
116
+
117
+ # Line-independent identity discriminator for an unnamed example:
118
+ # its 0-based ordinal among the *unnamed* examples of its group.
119
+ # Stable across blank-line / comment edits and across adding or
120
+ # renaming *named* siblings; changes only when the unnamed examples
121
+ # are reordered or one is inserted / removed ahead of it (the
122
+ # documented carve-out - see the module comment). Closes the
123
+ # remaining #196 gap reported in #210.
124
+ #
125
+ # The unnamed-siblings list is memoized per example_group object,
126
+ # so a group with N unnamed examples computes the list once, not N
127
+ # times. The discriminator string takes a Ruby-inspect-style
128
+ # `#<...>` form to make spoofing implausible - and even if a user
129
+ # description literally matched, full_description would still
130
+ # differ between the unnamed example (`"<group> "`) and the named
131
+ # one (`"<group> #<...>"`), so no real digest collision is
132
+ # possible.
133
+ # @api private
134
+ def self.unnamed_description(example)
135
+ group = example.example_group
136
+ unnamed_siblings = @unnamed_siblings_cache[group] ||=
137
+ group.examples.select { |sibling| unnamed?(sibling) }
15
138
 
16
- data.merge(example_id: Digest::MD5.hexdigest(data.to_json))
139
+ "#<rspec-tracer unnamed example #{unnamed_siblings.index(example)}>"
17
140
  end
18
141
 
19
- def example_location(example)
142
+ # Internal helper for the tracer pipeline.
143
+ # @api private
144
+ def self.example_location(example)
20
145
  metadata = example.metadata
21
146
 
22
147
  location = {
@@ -34,7 +159,9 @@ module RSpecTracer
34
159
  location.merge(example_rerun_location(example.example_group.parent_groups))
35
160
  end
36
161
 
37
- def example_rerun_location(example_groups)
162
+ # Internal helper for the tracer pipeline.
163
+ # @api private
164
+ def self.example_rerun_location(example_groups)
38
165
  example_groups.each do |example_group|
39
166
  metadata = example_group.metadata
40
167
 
@@ -47,12 +174,15 @@ module RSpecTracer
47
174
  end
48
175
  end
49
176
 
50
- def location_file_name(rspec_file_name)
177
+ # Internal helper for the tracer pipeline.
178
+ # @api private
179
+ def self.location_file_name(rspec_file_name)
51
180
  file_path = RSpecTracer::SourceFile.file_path(rspec_file_name)
52
181
 
53
182
  RSpecTracer::SourceFile.file_name(file_path)
54
183
  end
55
184
 
56
- private_class_method :example_location, :example_rerun_location, :location_file_name
185
+ private_class_method :digest_identity, :unnamed?, :unnamed_description,
186
+ :example_location, :example_rerun_location, :location_file_name
57
187
  end
58
188
  end
@@ -1,15 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RSpecTracer
4
+ # Internal Filter — see {RSpecTracer} for the user-facing surface.
5
+ # @api private
6
+ #
7
+ # Internal dispatch shim for the user-facing `add_filter` /
8
+ # `add_coverage_filter` DSL. The user passes a String / Regexp /
9
+ # Proc / Array; `Filter.register` wraps it in the matching subclass
10
+ # so the engine can call a uniform `#match?(source_file)`.
4
11
  class Filter
12
+ # Internal attribute.
13
+ # @api private
5
14
  attr_reader :filter
6
15
 
16
+ # Internal helper for the tracer pipeline.
17
+ # @api private
7
18
  def self.register(filter)
8
19
  return filter if filter.is_a?(Filter)
9
20
 
10
21
  filter_class(filter).new(filter)
11
22
  end
12
23
 
24
+ # Internal helper for the tracer pipeline.
25
+ # @api private
13
26
  def self.filter_class(filter)
14
27
  case filter
15
28
  when String
@@ -25,16 +38,24 @@ module RSpecTracer
25
38
  end
26
39
  end
27
40
 
41
+ # Internal method on the tracer pipeline.
42
+ # @api private
28
43
  def initialize(filter)
29
44
  @filter = filter
30
45
  end
31
46
 
47
+ # Internal method on the tracer pipeline.
48
+ # @api private
32
49
  def match?(_source_file)
33
50
  raise "#{self.class.name}#match? is not intended for direct use"
34
51
  end
35
52
  end
36
53
 
54
+ # Internal ArrayFilter — see {RSpecTracer} for the user-facing surface.
55
+ # @api private
37
56
  class ArrayFilter < RSpecTracer::Filter
57
+ # Internal method on the tracer pipeline.
58
+ # @api private
38
59
  def initialize(filters)
39
60
  filter_list = filters.each_with_object([]) do |filter, list|
40
61
  list << Filter.register(filter)
@@ -43,24 +64,38 @@ module RSpecTracer
43
64
  super(filter_list)
44
65
  end
45
66
 
67
+ # Internal method on the tracer pipeline.
68
+ # @api private
46
69
  def match?(source_file)
47
70
  @filter.any? { |filter| filter.match?(source_file) }
48
71
  end
49
72
  end
50
73
 
74
+ # Internal BlockFilter — see {RSpecTracer} for the user-facing surface.
75
+ # @api private
51
76
  class BlockFilter < RSpecTracer::Filter
77
+ # Internal method on the tracer pipeline.
78
+ # @api private
52
79
  def match?(source_file)
53
80
  @filter.call(source_file)
54
81
  end
55
82
  end
56
83
 
84
+ # Internal RegexFilter — see {RSpecTracer} for the user-facing surface.
85
+ # @api private
57
86
  class RegexFilter < RSpecTracer::Filter
87
+ # Internal method on the tracer pipeline.
88
+ # @api private
58
89
  def match?(source_file)
59
90
  source_file[:file_name] =~ @filter
60
91
  end
61
92
  end
62
93
 
94
+ # Internal StringFilter — see {RSpecTracer} for the user-facing surface.
95
+ # @api private
63
96
  class StringFilter < RSpecTracer::Filter
97
+ # Internal method on the tracer pipeline.
98
+ # @api private
64
99
  def match?(source_file)
65
100
  source_file[:file_name].include?(@filter)
66
101
  end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpecTracer
4
+ # Per-engine line-stub builder for files that need a synthetic
5
+ # all-nil-but-executable-lines coverage array. Used by
6
+ # `Reporters::CoverageJsonReporter` when a tracked file is in
7
+ # `coverage_tracked_files` but has no recorded coverage (file never
8
+ # loaded during this run).
9
+ #
10
+ # Lives at the top level of lib/rspec_tracer so the per-engine
11
+ # branches sit outside the tracker_coverage_gate's
12
+ # 100%-line+branch contract (the JRuby branch cannot be exercised
13
+ # on MRI; cross-engine coverage rollup would require fork-per-engine
14
+ # CI work that isn't justified for stub-line generation).
15
+ #
16
+ # `def self.x` per feedback_mutation_friendly_modules so future
17
+ # mutation gating maps to the singleton form.
18
+ module LineStub
19
+ # Internal helper for the tracer pipeline.
20
+ # @api private
21
+ def self.for(file_path)
22
+ case RUBY_ENGINE
23
+ when 'ruby'
24
+ ruby(file_path)
25
+ when 'jruby'
26
+ jruby(file_path)
27
+ else
28
+ File.foreach(file_path).map { nil }
29
+ end
30
+ end
31
+
32
+ # Internal helper for the tracer pipeline.
33
+ # @api private
34
+ def self.ruby(file_path)
35
+ lines = File.foreach(file_path).map { nil }
36
+ iseqs = [::RubyVM::InstructionSequence.compile_file(file_path)]
37
+ until iseqs.empty?
38
+ iseq = iseqs.pop
39
+ iseq.trace_points.each { |line_number, type| lines[line_number - 1] = 0 if type == :line }
40
+ iseq.each_child { |child| iseqs << child }
41
+ end
42
+ lines
43
+ end
44
+
45
+ # Internal helper for the tracer pipeline.
46
+ # @api private
47
+ def self.jruby(file_path)
48
+ lines = File.foreach(file_path).map { nil }
49
+ root_node = ::JRuby.parse(File.read(file_path, encoding: 'UTF-8'))
50
+ visitor = org.jruby.ast.visitor.NodeVisitor.impl do |_name, node|
51
+ if node.newline?
52
+ ln = node.respond_to?(:position) ? node.position.line : node.line
53
+ lines[ln] = 0
54
+ end
55
+ node.child_nodes.each { |child| child&.accept(visitor) }
56
+ end
57
+ root_node.accept(visitor)
58
+ lines
59
+ end
60
+ end
61
+ end
@@ -8,8 +8,8 @@ require_relative 'load_global_config'
8
8
  require_relative 'load_local_config'
9
9
 
10
10
  # NOTE: `Configuration#configure` installs the public DSL wrappers
11
- # (alias `_name` + redefine `name` to forward `|*args, &block|`) the
12
- # first time any configurer runs. `load_default_config` is
11
+ # (alias `_name` + redefine `name` to forward `|*args, **kwargs, &block|`)
12
+ # the first time any configurer runs. `load_default_config` is
13
13
  # unconditional, so the wrappers are guaranteed to exist before anyone
14
14
  # can call `RSpecTracer.add_filter` etc. No second install step is
15
15
  # needed here.
@@ -1,23 +1,38 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RSpecTracer
4
+ # Internal Logger — see {RSpecTracer} for the user-facing surface.
5
+ # @api private
6
+ #
7
+ # Internal logger; thin wrapper around `puts` gated on numeric log
8
+ # level. Exposed via `RSpecTracer.logger`.
4
9
  class Logger
10
+ # Internal method on the tracer pipeline.
11
+ # @api private
5
12
  def initialize(log_level)
6
13
  @log_level = log_level
7
14
  end
8
15
 
16
+ # Internal method on the tracer pipeline.
17
+ # @api private
9
18
  def debug(message)
10
19
  puts message if @log_level == 1
11
20
  end
12
21
 
22
+ # Internal method on the tracer pipeline.
23
+ # @api private
13
24
  def info(message)
14
25
  puts message if @log_level.between?(1, 2)
15
26
  end
16
27
 
28
+ # Internal method on the tracer pipeline.
29
+ # @api private
17
30
  def warn(message)
18
31
  puts message if @log_level.between?(1, 3)
19
32
  end
20
33
 
34
+ # Internal method on the tracer pipeline.
35
+ # @api private
21
36
  def error(message)
22
37
  puts message if @log_level.between?(1, 4)
23
38
  end
@@ -0,0 +1,78 @@
1
+ # Rails integration
2
+
3
+ Loaded by `require 'rspec_tracer/rails'`. Exposes the Rails preset, the
4
+ Rails-side observer family, and a Railtie that integrates with the Rails
5
+ lifecycle when Rails is present in the process.
6
+
7
+ ## Surface
8
+
9
+ - **`RSpecTracer::Rails::Preset`** - closed-enum glob set covering the
10
+ Coverage-invisible Rails surface (views, helpers, locales, config
11
+ YAML, schema, factories, fixtures). Opt in via `track_rails_defaults`
12
+ in `.rspec-tracer`; opt out per category via
13
+ `track_rails_defaults except: [:views, :locales]`.
14
+ - **`RSpecTracer::Rails::Railtie`** - only loaded when
15
+ `defined?(::Rails::Railtie)`. Registers a single `rspec_tracer.setup`
16
+ initializer that logs a confirmation line when Rails boots.
17
+ - **`RSpecTracer::Rails::Notifications`** - ActiveSupport::Notifications
18
+ observer for the render_template / render_partial / render_collection
19
+ events (ActionView) plus an opt-in sql.active_record subscriber for
20
+ narrow schema attribution. Installed by `Engine.setup` when
21
+ `RSpecTracer.rails?` is true. Emits `:template` Inputs for observed
22
+ template renders and `:notification` Inputs for schema files on the
23
+ first AR query per example.
24
+ - **`RSpecTracer::Rails::I18nTracking`** - prepends onto
25
+ `::I18n::Backend::Base#load_translations` so every I18n backend
26
+ (including custom Redis/DB/Chain backends that bypass
27
+ `YAML.load_file`) emits `:notification` Inputs for the translation
28
+ files it loads.
29
+
30
+ ## Detection
31
+
32
+ `RSpecTracer.rails?` returns true when `::Rails::VERSION` is defined at
33
+ the time `RSpecTracer.start` runs. The flag is computed once during
34
+ `initial_setup`; subsequent Rails loads do not flip it.
35
+
36
+ ## Zero-cost when Rails is absent
37
+
38
+ `require 'rspec_tracer/rails'` loads Preset unconditionally and skips
39
+ the Railtie via `defined?(::Rails::Railtie)`. Notifications and
40
+ I18nTracking are required lazily by `Engine.setup` only when
41
+ `RSpecTracer.rails?` is truthy. A pure-Ruby suite that accidentally
42
+ requires the file never pays for Rails-specific code.
43
+
44
+ ## Narrow schema attribution (opt-in)
45
+
46
+ By default, `track_rails_defaults` attaches `db/schema.rb` and
47
+ `db/structure.sql` to every example via the Preset's `:schema`
48
+ declared-glob - a conservative whole-suite signal. Teams that want
49
+ schema changes to re-run only examples that actually touched AR can
50
+ opt into an `sql.active_record` subscriber:
51
+
52
+ ```ruby
53
+ RSpecTracer.configure do
54
+ track_rails_defaults except: [:schema]
55
+ track_ar_schema_notifications
56
+ end
57
+ ```
58
+
59
+ On the first `sql.active_record` event inside an example, the
60
+ subscriber emits `:notification` Inputs for `db/schema.rb` and
61
+ `db/structure.sql` if they exist under the project root, then
62
+ short-circuits for the remainder of that example. A non-DB-touching
63
+ example never sees the schema inputs and is not re-run on schema edits.
64
+
65
+ Leaving `:schema` in `track_rails_defaults` while also enabling the
66
+ subscriber is a no-op in terms of the re-run set - declared-glob
67
+ dominates at graph registration.
68
+
69
+ ## Components
70
+
71
+ - **Preset + detection + Railtie scaffold.**
72
+ - **Notifications + I18nTracking observers**, the
73
+ `track_ar_schema_notifications` opt-in DSL, and `Engine.setup`
74
+ wiring. Factory and fixture coverage ride the existing
75
+ `LoadedFilesTracker` / `YAML.load_file` hook surface.
76
+ - **Integration coverage** against the reference Rails app
77
+ (`spec/fixtures/rails_app/`) verifying the full "change X -> Y
78
+ re-runs" behavior matrix.
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../tracker/file_digest'
4
+ require_relative '../tracker/input'
5
+ require_relative 'notifications'
6
+
7
+ module RSpecTracer
8
+ # Internal Rails — see {RSpecTracer} for the user-facing surface.
9
+ # @api private
10
+ module Rails
11
+ # I18n backend observer. Covers custom backends (Redis-backed,
12
+ # DB-backed, Chain) that bypass YAML.load_file and would otherwise
13
+ # miss the IOHooks YAML hook.
14
+ #
15
+ # Mechanism: Module#prepend onto ::I18n::Backend::Base - every
16
+ # backend subclass's load_translations resolves through the hook,
17
+ # even when the subclass overrides and super-calls Base. Backends
18
+ # that never super-call Base#load_translations fall through the
19
+ # hook, but the common backends (Simple, Chain, Cascade) all do.
20
+ #
21
+ # Shares Notifications' thread-local bucket so Engine.setup opens
22
+ # and clears one bucket per example that covers both observer
23
+ # families. Engine harvests `bucket.values` at example_finished.
24
+ #
25
+ # Graceful degradation:
26
+ # - install no-ops if ::I18n::Backend::Base is absent (tracer
27
+ # boot survives even in weird I18n-free app graphs).
28
+ # - Every record call swallows StandardError (CLAUDE.md) - a
29
+ # digest failure or bucket-shape surprise never propagates into
30
+ # the user's test run.
31
+ class I18nTracking
32
+ class << self
33
+ # Internal attribute.
34
+ # @api private
35
+ attr_reader :root
36
+
37
+ # Internal method on the tracer pipeline.
38
+ # @api private
39
+ def install(root:, filter: ->(_path) { true })
40
+ @root = File.expand_path(root)
41
+ @root_prefix = "#{@root}/"
42
+ @filter = filter
43
+ @prepended = prepend_backend_hook
44
+
45
+ self
46
+ end
47
+
48
+ # Prepended modules cannot be removed from the ancestry chain
49
+ # (Ruby has no public API), mirroring IOHooks.uninstall. Every
50
+ # hook entry point fast-rejects on @root_prefix nil once
51
+ # install state clears, so post-uninstall the hook is a no-op.
52
+ def uninstall
53
+ @root = nil
54
+ @root_prefix = nil
55
+ @filter = nil
56
+ @prepended = false
57
+ self
58
+ end
59
+
60
+ # Internal method on the tracer pipeline.
61
+ # @api private
62
+ def installed?
63
+ !@root_prefix.nil?
64
+ end
65
+
66
+ # Called from LoadTranslationsHook for every filename passed
67
+ # to I18n::Backend::Base#load_translations. Array form keeps
68
+ # the hook a single call site.
69
+ def record_translations(filenames)
70
+ return nil if @root_prefix.nil?
71
+
72
+ Array(filenames).each { |path| record_translation(path) }
73
+ nil
74
+ end
75
+
76
+ # Pure-logic entry point. Same fast-reject ladder as
77
+ # Notifications#record_template. Emits :notification kind
78
+ # so the I18n source is distinguishable from template events
79
+ # in downstream reporters. The ladder is longer than rubocop's
80
+ # perceived-complexity threshold by design - each guard is a
81
+ # cheap fast-reject.
82
+ # rubocop:disable Metrics/PerceivedComplexity
83
+ def record_translation(path)
84
+ return nil if @root_prefix.nil?
85
+
86
+ bucket = Notifications.current_bucket
87
+ return nil if bucket.nil?
88
+ return nil unless path.is_a?(String) || path.respond_to?(:to_s)
89
+
90
+ path_str = path.to_s
91
+ return nil unless path_str.start_with?(@root_prefix)
92
+ return nil unless @filter.call(path_str)
93
+
94
+ identity = "notification:#{path_str[@root_prefix.length..]}"
95
+ return nil if bucket.key?(identity)
96
+
97
+ digest = Tracker::FileDigest.compute(path_str)
98
+ return nil if digest.nil?
99
+
100
+ bucket[identity] = Tracker::Input.for_file(
101
+ path: path_str, kind: :notification, digest: digest, root: @root
102
+ )
103
+ rescue StandardError
104
+ nil
105
+ end
106
+ # rubocop:enable Metrics/PerceivedComplexity
107
+
108
+ private
109
+
110
+ # Internal method on the tracer pipeline.
111
+ # @api private
112
+ def prepend_backend_hook
113
+ return false unless defined?(::I18n::Backend::Base)
114
+
115
+ ::I18n::Backend::Base.prepend(LoadTranslationsHook)
116
+ true
117
+ rescue StandardError
118
+ false
119
+ end
120
+ end
121
+
122
+ # Prepended onto I18n::Backend::Base. Every subclass's
123
+ # load_translations ultimately resolves through here via super.
124
+ # Intercepts the filename list, delegates recording to the
125
+ # singleton, and forwards to the real implementation.
126
+ # @api private
127
+ module LoadTranslationsHook
128
+ # Internal method on the tracer pipeline.
129
+ # @api private
130
+ def load_translations(*filenames)
131
+ RSpecTracer::Rails::I18nTracking.record_translations(filenames)
132
+ super
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end