rspec-tracer 1.2.2 → 2.0.0.pre.1

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 +197 -45
  3. data/README.md +439 -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 +98 -0
  7. data/lib/rspec_tracer/cli/cache_info.rb +103 -0
  8. data/lib/rspec_tracer/cli/doctor.rb +275 -0
  9. data/lib/rspec_tracer/cli/explain.rb +148 -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 +1100 -3
  13. data/lib/rspec_tracer/engine.rb +1076 -0
  14. data/lib/rspec_tracer/example.rb +21 -6
  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 +397 -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 +178 -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 +68 -0
  75. data/lib/rspec_tracer/storage/json_backend.rb +866 -0
  76. data/lib/rspec_tracer/storage/lazy_snapshot.rb +65 -0
  77. data/lib/rspec_tracer/storage/schema.rb +43 -0
  78. data/lib/rspec_tracer/storage/serializer/json.rb +41 -0
  79. data/lib/rspec_tracer/storage/serializer/msgpack.rb +90 -0
  80. data/lib/rspec_tracer/storage/snapshot.rb +127 -0
  81. data/lib/rspec_tracer/storage/sqlite_backend.rb +686 -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 +232 -381
  104. metadata +93 -43
  105. data/lib/rspec_tracer/cache.rb +0 -207
  106. data/lib/rspec_tracer/coverage_merger.rb +0 -42
  107. data/lib/rspec_tracer/coverage_reporter.rb +0 -187
  108. data/lib/rspec_tracer/coverage_writer.rb +0 -58
  109. data/lib/rspec_tracer/html_reporter/Rakefile +0 -18
  110. data/lib/rspec_tracer/html_reporter/assets/javascripts/application.js +0 -56
  111. data/lib/rspec_tracer/html_reporter/assets/javascripts/libraries/jquery.js +0 -10881
  112. data/lib/rspec_tracer/html_reporter/assets/javascripts/plugins/datatables.js +0 -15381
  113. data/lib/rspec_tracer/html_reporter/assets/stylesheets/application.css +0 -196
  114. data/lib/rspec_tracer/html_reporter/assets/stylesheets/plugins/datatables.css +0 -459
  115. data/lib/rspec_tracer/html_reporter/assets/stylesheets/plugins/jquery-ui.css +0 -436
  116. data/lib/rspec_tracer/html_reporter/assets/stylesheets/print.css +0 -92
  117. data/lib/rspec_tracer/html_reporter/assets/stylesheets/reset.css +0 -265
  118. data/lib/rspec_tracer/html_reporter/public/application.css +0 -5
  119. data/lib/rspec_tracer/html_reporter/public/application.js +0 -6
  120. data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_asc.png +0 -0
  121. data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_asc_disabled.png +0 -0
  122. data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_both.png +0 -0
  123. data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_desc.png +0 -0
  124. data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_desc_disabled.png +0 -0
  125. data/lib/rspec_tracer/html_reporter/public/favicon.png +0 -0
  126. data/lib/rspec_tracer/html_reporter/public/loading.gif +0 -0
  127. data/lib/rspec_tracer/html_reporter/reporter.rb +0 -242
  128. data/lib/rspec_tracer/html_reporter/views/duplicate_examples.erb +0 -34
  129. data/lib/rspec_tracer/html_reporter/views/examples.erb +0 -58
  130. data/lib/rspec_tracer/html_reporter/views/examples_dependency.erb +0 -36
  131. data/lib/rspec_tracer/html_reporter/views/files_dependency.erb +0 -36
  132. data/lib/rspec_tracer/html_reporter/views/flaky_examples.erb +0 -38
  133. data/lib/rspec_tracer/html_reporter/views/layout.erb +0 -38
  134. data/lib/rspec_tracer/remote_cache/aws.rb +0 -176
  135. data/lib/rspec_tracer/remote_cache/cache.rb +0 -75
  136. data/lib/rspec_tracer/remote_cache/repo.rb +0 -210
  137. data/lib/rspec_tracer/report_generator.rb +0 -158
  138. data/lib/rspec_tracer/report_merger.rb +0 -68
  139. data/lib/rspec_tracer/report_writer.rb +0 -141
  140. data/lib/rspec_tracer/reporter.rb +0 -204
  141. data/lib/rspec_tracer/rspec_reporter.rb +0 -41
  142. data/lib/rspec_tracer/rspec_runner.rb +0 -56
  143. data/lib/rspec_tracer/ruby_coverage.rb +0 -9
  144. data/lib/rspec_tracer/runner.rb +0 -278
@@ -1,10 +1,19 @@
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
+ # All methods declared `def self.x` + `private_class_method` for
8
+ # the private helpers per feedback_mutation_friendly_modules:
9
+ # mutant maps tests to the singleton form. Public API
10
+ # (`Example.from`) is the only call site `runner_hook.rb` + the
11
+ # runner-hook spec drive; the three private helpers stay reachable
12
+ # only from `from` itself.
4
13
  module Example
5
- module_function
6
-
7
- def from(example)
14
+ # Internal helper for the tracer pipeline.
15
+ # @api private
16
+ def self.from(example)
8
17
  data = {
9
18
  example_group: example.example_group.name,
10
19
  description: example.description,
@@ -16,7 +25,9 @@ module RSpecTracer
16
25
  data.merge(example_id: Digest::MD5.hexdigest(data.to_json))
17
26
  end
18
27
 
19
- def example_location(example)
28
+ # Internal helper for the tracer pipeline.
29
+ # @api private
30
+ def self.example_location(example)
20
31
  metadata = example.metadata
21
32
 
22
33
  location = {
@@ -34,7 +45,9 @@ module RSpecTracer
34
45
  location.merge(example_rerun_location(example.example_group.parent_groups))
35
46
  end
36
47
 
37
- def example_rerun_location(example_groups)
48
+ # Internal helper for the tracer pipeline.
49
+ # @api private
50
+ def self.example_rerun_location(example_groups)
38
51
  example_groups.each do |example_group|
39
52
  metadata = example_group.metadata
40
53
 
@@ -47,7 +60,9 @@ module RSpecTracer
47
60
  end
48
61
  end
49
62
 
50
- def location_file_name(rspec_file_name)
63
+ # Internal helper for the tracer pipeline.
64
+ # @api private
65
+ def self.location_file_name(rspec_file_name)
51
66
  file_path = RSpecTracer::SourceFile.file_path(rspec_file_name)
52
67
 
53
68
  RSpecTracer::SourceFile.file_name(file_path)
@@ -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