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
data/bin/rspec-tracer ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Entry point for the `rspec-tracer` CLI. The binary is opt-in for
5
+ # users who want diagnostics, cache inspection, or report-opening
6
+ # from the command line; the canonical CI flow continues to go
7
+ # through `rake rspec_tracer:remote_cache:*` tasks per
8
+ # USER_FACING_SURFACE.md §5.
9
+
10
+ lib_dir = File.expand_path('../lib', __dir__)
11
+ $LOAD_PATH.unshift(lib_dir) unless $LOAD_PATH.include?(lib_dir)
12
+
13
+ require 'rspec_tracer/cli'
14
+
15
+ exit RSpecTracer::CLI.run(ARGV)
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ # User-facing Rake task shim for local-cache maintenance. Load from
4
+ # the user's own Rakefile when they want one-off cleanup:
5
+ #
6
+ # spec = Gem::Specification.find_by_name('rspec-tracer')
7
+ # load "#{spec.gem_dir}/lib/rspec_tracer/cache/Rakefile"
8
+ #
9
+ # Defines `rspec_tracer:cache:gc` which prunes old run-id
10
+ # directories under the local cache while retaining the `N`
11
+ # most-recent configured via `cache_retention_local_count` (default
12
+ # 5). Prune-on-save already handles the steady-state cleanup; this
13
+ # task is for catching up after retention was opted-out-of and the
14
+ # cache grew beyond the configured cap.
15
+
16
+ require 'rspec_tracer'
17
+ require 'rspec_tracer/storage/json_backend'
18
+
19
+ namespace :rspec_tracer do
20
+ namespace :cache do
21
+ desc 'Prune old run-id directories under the local cache ' \
22
+ '(respects cache_retention_local_count)'
23
+ task :gc do
24
+ count = RSpecTracer.cache_retention_local_count
25
+ if count.nil? || count.zero?
26
+ RSpecTracer.logger.warn(
27
+ 'rspec-tracer cache gc: cache_retention_local_count is 0 (opt-out); nothing to prune'
28
+ )
29
+ next
30
+ end
31
+
32
+ backend = RSpecTracer::Storage::JsonBackend.new(
33
+ cache_path: RSpecTracer.cache_path,
34
+ logger: RSpecTracer.logger,
35
+ retention_local_count: count
36
+ )
37
+ removed = backend.prune_run_dirs!(keep: count)
38
+ RSpecTracer.logger.info(
39
+ "rspec-tracer cache gc: pruned #{removed} run-id dir(s); kept #{count} most-recent"
40
+ )
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module RSpecTracer
6
+ # Internal CLI — see {RSpecTracer} for the user-facing surface.
7
+ # @api private
8
+ module CLI
9
+ # `rspec-tracer cache:clear` — remove cache, coverage, and report
10
+ # directories. Prompts for confirmation unless `--yes` is passed.
11
+ # The next rspec run is a cold run.
12
+ module CacheClear
13
+ # @param args [Array<String>] sub-command args (`-y` / `--yes`
14
+ # skips confirmation; `-h` / `--help` prints help).
15
+ # @param stdout [IO]
16
+ # @param stderr [IO]
17
+ # @return [Integer] exit status (0 = success / aborted, 1 = error).
18
+ def self.run(args, stdout: $stdout, stderr: $stderr)
19
+ return print_help(stdout) if args.include?('-h') || args.include?('--help')
20
+
21
+ require 'rspec_tracer/load_config'
22
+ existing = existing_targets
23
+ return nothing_to_remove(stdout) if existing.empty?
24
+
25
+ announce(stdout, existing)
26
+ force = args.include?('--yes') || args.include?('-y')
27
+ return aborted(stdout) unless force || confirm?(stdout)
28
+
29
+ remove_each(stdout, existing)
30
+ 0
31
+ rescue StandardError => e
32
+ stderr.puts "cache:clear: #{e.class}: #{e.message}"
33
+ 1
34
+ end
35
+
36
+ # Internal helper for the tracer pipeline.
37
+ # @api private
38
+ def self.existing_targets
39
+ [RSpecTracer.cache_path, RSpecTracer.coverage_path, RSpecTracer.report_path]
40
+ .select { |path| File.directory?(path) }
41
+ end
42
+
43
+ # Internal helper for the tracer pipeline.
44
+ # @api private
45
+ def self.nothing_to_remove(stdout)
46
+ stdout.puts 'cache:clear: nothing to remove (cache directories do not exist)'
47
+ 0
48
+ end
49
+
50
+ # Internal helper for the tracer pipeline.
51
+ # @api private
52
+ def self.announce(stdout, existing)
53
+ stdout.puts 'cache:clear: will remove:'
54
+ existing.each { |path| stdout.puts " - #{path}" }
55
+ end
56
+
57
+ # Internal helper for the tracer pipeline.
58
+ # @api private
59
+ def self.confirm?(stdout)
60
+ stdout.print 'Proceed? [y/N] '
61
+ stdout.flush
62
+ response = $stdin.gets&.chomp&.downcase
63
+ %w[y yes].include?(response)
64
+ end
65
+
66
+ # Internal helper for the tracer pipeline.
67
+ # @api private
68
+ def self.aborted(stdout)
69
+ stdout.puts 'cache:clear: aborted'
70
+ 0
71
+ end
72
+
73
+ # Internal helper for the tracer pipeline.
74
+ # @api private
75
+ def self.remove_each(stdout, existing)
76
+ existing.each do |path|
77
+ FileUtils.rm_rf(path)
78
+ stdout.puts " removed #{path}"
79
+ end
80
+ end
81
+
82
+ # Internal helper for the tracer pipeline.
83
+ # @api private
84
+ def self.print_help(stdout)
85
+ stdout.puts <<~HELP
86
+ Usage: rspec-tracer cache:clear [--yes]
87
+
88
+ Remove cache, coverage, and report directories. The next rspec
89
+ run will be a cold run (full re-execution + cache rebuild).
90
+
91
+ Options:
92
+ -y, --yes Skip the confirmation prompt.
93
+ HELP
94
+ 0
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module RSpecTracer
6
+ # Internal CLI — see {RSpecTracer} for the user-facing surface.
7
+ # @api private
8
+ module CLI
9
+ # `rspec-tracer cache:info` — show cache size, last run, and
10
+ # invalidation stats. Reads `last_run.json` + the run-id'd JSON
11
+ # files written by Storage::JsonBackend.
12
+ module CacheInfo
13
+ # @param args [Array<String>] sub-command args (`-h` / `--help`).
14
+ # @param stdout [IO]
15
+ # @param stderr [IO]
16
+ # @return [Integer] exit status (0 = success).
17
+ def self.run(args, stdout: $stdout, stderr: $stderr)
18
+ return print_help(stdout) if args.include?('-h') || args.include?('--help')
19
+
20
+ require 'rspec_tracer/load_config'
21
+
22
+ cache_path = RSpecTracer.cache_path
23
+ stdout.puts "cache_path: #{cache_path}"
24
+ stdout.puts "size: #{format_bytes(directory_size(cache_path))}"
25
+
26
+ last_run_path = File.join(cache_path, 'last_run.json')
27
+ unless File.file?(last_run_path)
28
+ stdout.puts 'last_run: no last_run.json yet (run rspec first)'
29
+ return 0
30
+ end
31
+
32
+ manifest = JSON.parse(File.read(last_run_path, encoding: 'UTF-8'))
33
+ run_id = manifest['run_id']
34
+ stdout.puts "last_run: #{run_id}"
35
+ stdout.puts "generated: #{manifest['generated_at']}" if manifest['generated_at']
36
+
37
+ print_run_summary(stdout, cache_path, run_id) if run_id
38
+ 0
39
+ rescue StandardError => e
40
+ stderr.puts "cache:info: #{e.class}: #{e.message}"
41
+ 1
42
+ end
43
+
44
+ # Internal helper for the tracer pipeline.
45
+ # @api private
46
+ def self.print_help(stdout)
47
+ stdout.puts <<~HELP
48
+ Usage: rspec-tracer cache:info
49
+
50
+ Show the on-disk cache size, the last run id, and example counts
51
+ for the most recent run. Reads `last_run.json` plus the run-id'd
52
+ JSON files; does not modify any files.
53
+ HELP
54
+ 0
55
+ end
56
+
57
+ # Internal helper for the tracer pipeline.
58
+ # @api private
59
+ def self.print_run_summary(stdout, cache_path, run_id)
60
+ run_dir = File.join(cache_path, run_id)
61
+ return unless File.directory?(run_dir)
62
+
63
+ all_examples_path = File.join(run_dir, 'all_examples.json')
64
+ return unless File.file?(all_examples_path)
65
+
66
+ data = JSON.parse(File.read(all_examples_path, encoding: 'UTF-8'))
67
+ total = data.size
68
+ stdout.puts "examples: #{total} tracked"
69
+ end
70
+
71
+ # Internal helper for the tracer pipeline.
72
+ # @api private
73
+ def self.directory_size(path)
74
+ return 0 unless File.directory?(path)
75
+
76
+ total = 0
77
+ Dir.glob(File.join(path, '**', '*'), File::FNM_DOTMATCH).each do |entry|
78
+ next unless File.file?(entry)
79
+
80
+ total += File.size(entry)
81
+ rescue SystemCallError
82
+ next
83
+ end
84
+ total
85
+ end
86
+
87
+ # Internal helper for the tracer pipeline.
88
+ # @api private
89
+ def self.format_bytes(bytes)
90
+ return '0 B' if bytes <= 0
91
+
92
+ units = %w[B KB MB GB]
93
+ scale = bytes
94
+ unit_index = 0
95
+ while scale >= 1024 && unit_index < units.length - 1
96
+ scale /= 1024.0
97
+ unit_index += 1
98
+ end
99
+ format('%<scale>.1f %<unit>s', scale: scale, unit: units[unit_index])
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,275 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpecTracer
4
+ # Internal CLI — see {RSpecTracer} for the user-facing surface.
5
+ # @api private
6
+ module CLI
7
+ # `rspec-tracer doctor` — diagnose config + environment.
8
+ # Reports Ruby + rspec-tracer versions, project root resolution,
9
+ # cache / coverage / report directory state, and SimpleCov / Rails
10
+ # presence. Exits 0 on healthy diagnosis, 1 if any check fails.
11
+ module Doctor
12
+ # @param args [Array<String>] sub-command args (`-h` / `--help`).
13
+ # @param stdout [IO]
14
+ # @param stderr [IO]
15
+ # @return [Integer] exit status (0 = healthy, 1 = any check
16
+ # FAILed; warnings keep status 0).
17
+ def self.run(args, stdout: $stdout, stderr: $stderr)
18
+ return print_help(stdout) if args.include?('-h') || args.include?('--help')
19
+
20
+ require 'rspec_tracer/load_config'
21
+
22
+ checks = [
23
+ ruby_version_check,
24
+ tracer_version_check,
25
+ project_root_check,
26
+ cache_path_check,
27
+ coverage_path_check,
28
+ report_path_check,
29
+ git_check,
30
+ simplecov_check,
31
+ rails_check,
32
+ cache_schema_version_check,
33
+ remote_cache_check,
34
+ ar_schema_narrow_attribution_check
35
+ ]
36
+ checks.each { |line| stdout.puts line }
37
+ ok = checks.none? { |line| line.start_with?('FAIL') }
38
+ ok ? 0 : 1
39
+ rescue StandardError => e
40
+ stderr.puts "doctor: #{e.class}: #{e.message}"
41
+ 1
42
+ end
43
+
44
+ # Internal helper for the tracer pipeline.
45
+ # @api private
46
+ def self.print_help(stdout)
47
+ stdout.puts <<~HELP
48
+ Usage: rspec-tracer doctor
49
+
50
+ Diagnose rspec-tracer config and environment. Prints a checklist
51
+ of versions, paths, and integrations; exits 0 if all checks pass.
52
+ HELP
53
+ 0
54
+ end
55
+
56
+ # Internal helper for the tracer pipeline.
57
+ # @api private
58
+ def self.ruby_version_check
59
+ "OK ruby: #{RUBY_DESCRIPTION}"
60
+ end
61
+
62
+ # Internal helper for the tracer pipeline.
63
+ # @api private
64
+ def self.tracer_version_check
65
+ "OK rspec-tracer: #{RSpecTracer::VERSION}"
66
+ end
67
+
68
+ # Internal helper for the tracer pipeline.
69
+ # @api private
70
+ def self.project_root_check
71
+ "OK root: #{RSpecTracer.root}"
72
+ end
73
+
74
+ # Internal helper for the tracer pipeline.
75
+ # @api private
76
+ def self.cache_path_check
77
+ path_check('cache_path:', RSpecTracer.cache_path)
78
+ end
79
+
80
+ # Internal helper for the tracer pipeline.
81
+ # @api private
82
+ def self.coverage_path_check
83
+ path_check('coverage_path:', RSpecTracer.coverage_path)
84
+ end
85
+
86
+ # Internal helper for the tracer pipeline.
87
+ # @api private
88
+ def self.report_path_check
89
+ path_check('report_path:', RSpecTracer.report_path)
90
+ end
91
+
92
+ # Internal helper for the tracer pipeline.
93
+ # @api private
94
+ def self.path_check(label, path)
95
+ return "FAIL #{label} <missing>" if path.nil? || path.empty?
96
+ return "FAIL #{label} #{path} (does not exist)" unless File.directory?(path)
97
+ return "FAIL #{label} #{path} (not writable)" unless File.writable?(path)
98
+
99
+ "OK #{label} #{path}"
100
+ end
101
+
102
+ # Internal helper for the tracer pipeline.
103
+ # @api private
104
+ def self.git_check
105
+ if system('git', 'rev-parse', 'HEAD', out: File::NULL, err: File::NULL)
106
+ 'OK git: HEAD reachable (remote_cache will work)'
107
+ else
108
+ 'WARN git: not in a git repo (remote_cache will degrade gracefully)'
109
+ end
110
+ end
111
+
112
+ # Internal helper for the tracer pipeline.
113
+ # @api private
114
+ def self.simplecov_check
115
+ if defined?(::SimpleCov)
116
+ 'OK SimpleCov: loaded (interop active)'
117
+ else
118
+ 'INFO SimpleCov: not loaded (this is fine; SimpleCov is optional)'
119
+ end
120
+ end
121
+
122
+ # Internal helper for the tracer pipeline.
123
+ # @api private
124
+ def self.rails_check
125
+ if defined?(::Rails::VERSION) && !::Rails::VERSION.nil?
126
+ "OK Rails: #{::Rails::VERSION::STRING}"
127
+ else
128
+ 'INFO Rails: not loaded (this is fine for non-Rails projects)'
129
+ end
130
+ end
131
+
132
+ # Surface a 1.x->2.0 cache mismatch BEFORE the user runs
133
+ # rspec and watches everything re-run mysteriously. Reads the
134
+ # cached `last_run.json` (if any) and compares its
135
+ # `schema_version` against the gem's `Schema::CURRENT`. Three
136
+ # outcomes: no cache yet (INFO), match (OK), or mismatch (WARN
137
+ # with the cold-run note). Never FAIL - schema mismatches are
138
+ # the documented cold-run path, not a hard error.
139
+ def self.cache_schema_version_check
140
+ require 'rspec_tracer/storage/schema'
141
+ require 'json'
142
+
143
+ cache_path = RSpecTracer.cache_path
144
+ last_run_path = File.join(cache_path.to_s, 'last_run.json')
145
+ unless File.file?(last_run_path)
146
+ return 'INFO schema: no cache yet (next rspec run is cold; expected on first install)'
147
+ end
148
+
149
+ manifest = JSON.parse(File.read(last_run_path, encoding: 'UTF-8'))
150
+ stored = manifest['schema_version']
151
+ current = RSpecTracer::Storage::Schema::CURRENT
152
+ if stored == current
153
+ "OK schema: v#{current} (matches gem)"
154
+ else
155
+ "WARN schema: stored v#{stored.inspect} != gem v#{current} " \
156
+ '(next rspec run is a cold run; expected on 1.x->2.0 upgrade)'
157
+ end
158
+ rescue StandardError => e
159
+ "WARN schema: could not read cache manifest: #{e.class}: #{e.message}"
160
+ end
161
+
162
+ # When remote_cache is configured, verify the backend is
163
+ # reachable from doctor's vantage point so the user catches
164
+ # misconfig (typo'd S3 path / unreachable Redis URL / unwritable
165
+ # local-fs dir) BEFORE the next CI run fails. Best-effort: never
166
+ # FAIL the gate, just surface a WARN/INFO line.
167
+ REMOTE_CACHE_PROBES = {
168
+ s3: ->(opts) { remote_cache_s3_check(opts) },
169
+ local_fs: ->(opts) { remote_cache_local_fs_check(opts) },
170
+ redis: ->(opts) { remote_cache_redis_check(opts) }
171
+ }.freeze
172
+
173
+ # Internal helper for the tracer pipeline.
174
+ # @api private
175
+ def self.remote_cache_check
176
+ entry = remote_cache_entry
177
+ return 'INFO remote_cache: not configured (skip)' unless entry
178
+
179
+ backend, opts = entry
180
+ probe = REMOTE_CACHE_PROBES[backend]
181
+ return "INFO remote_cache: custom backend #{backend.inspect} (skipping reachability probe)" if probe.nil?
182
+
183
+ instance_exec(opts, &probe)
184
+ end
185
+
186
+ # Internal helper for the tracer pipeline.
187
+ # @api private
188
+ def self.remote_cache_entry
189
+ return nil unless RSpecTracer.respond_to?(:remote_cache_backend_entry)
190
+
191
+ RSpecTracer.remote_cache_backend_entry
192
+ end
193
+
194
+ # Internal helper for the tracer pipeline.
195
+ # @api private
196
+ def self.remote_cache_s3_check(opts)
197
+ bucket = opts[:bucket] || opts['bucket']
198
+ return 'WARN remote_cache: :s3 configured without :bucket' if bucket.nil? || bucket.empty?
199
+
200
+ "OK remote_cache: :s3 bucket=#{bucket} " \
201
+ '(reachability not probed locally; verified end-to-end on next CI run)'
202
+ end
203
+
204
+ # Internal helper for the tracer pipeline.
205
+ # @api private
206
+ def self.remote_cache_local_fs_check(opts)
207
+ path = opts[:path] || opts['path']
208
+ return 'WARN remote_cache: :local_fs configured without :path' if path.nil? || path.empty?
209
+ return "WARN remote_cache: :local_fs path #{path} does not exist" unless File.directory?(path)
210
+ return "WARN remote_cache: :local_fs path #{path} not writable" unless File.writable?(path)
211
+
212
+ "OK remote_cache: :local_fs path=#{path}"
213
+ end
214
+
215
+ # Internal helper for the tracer pipeline.
216
+ # @api private
217
+ def self.remote_cache_redis_check(opts)
218
+ url = opts[:url] || opts['url'] || ENV.fetch('RSPEC_TRACER_REMOTE_CACHE_URI', nil)
219
+ return 'WARN remote_cache: :redis configured without :url' if url.nil? || url.empty?
220
+
221
+ "OK remote_cache: :redis url=#{url} " \
222
+ '(reachability not probed locally; verified end-to-end on next CI run)'
223
+ end
224
+
225
+ # Surface the narrow-attribution precondition at diagnostic time.
226
+ # When `track_ar_schema_notifications` is enabled AND Rails is
227
+ # loaded AND the rspec-rails default `use_transactional_fixtures
228
+ # = true` is in effect, per-example BEGIN/COMMIT fires
229
+ # `sql.active_record` inside the rspec-tracer bucket and
230
+ # attribution silently widens. Same shape as the boot-time warn
231
+ # in RSpecTracer.start, surfaced here so users running
232
+ # `rspec-tracer doctor` see the issue without having to boot a
233
+ # full rspec run first.
234
+ def self.ar_schema_narrow_attribution_check
235
+ return 'INFO AR schema: track_ar_schema_notifications not enabled' unless ar_schema_enabled?
236
+ return 'INFO AR schema: Rails not loaded' unless rails_loaded?
237
+
238
+ if transactional_fixtures_default?
239
+ 'WARN AR schema: track_ar_schema_notifications + use_transactional_fixtures=true ' \
240
+ 'silently widens to whole-suite-on-schema-change. See README ' \
241
+ 'section "Narrow AR schema attribution".'
242
+ else
243
+ 'OK AR schema: narrow attribution preconditions look good'
244
+ end
245
+ end
246
+
247
+ # Internal helper for the tracer pipeline.
248
+ # @api private
249
+ def self.ar_schema_enabled?
250
+ return false unless RSpecTracer.respond_to?(:track_ar_schema_notifications?)
251
+
252
+ RSpecTracer.track_ar_schema_notifications?
253
+ end
254
+
255
+ # Internal helper for the tracer pipeline.
256
+ # @api private
257
+ def self.rails_loaded?
258
+ defined?(::Rails::VERSION) && !::Rails::VERSION.nil?
259
+ end
260
+
261
+ # Internal helper for the tracer pipeline.
262
+ # @api private
263
+ def self.transactional_fixtures_default?
264
+ return false unless defined?(::RSpec) && ::RSpec.respond_to?(:configuration)
265
+
266
+ cfg = ::RSpec.configuration
267
+ return false unless cfg.respond_to?(:use_transactional_fixtures)
268
+
269
+ cfg.use_transactional_fixtures != false
270
+ rescue StandardError
271
+ false
272
+ end
273
+ end
274
+ end
275
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module RSpecTracer
6
+ # Internal CLI — see {RSpecTracer} for the user-facing surface.
7
+ # @api private
8
+ module CLI
9
+ # `rspec-tracer explain <example>` — show why a given example is
10
+ # scheduled to run or skip on the next rspec invocation. Reads the
11
+ # most recent run's JSON files (all_examples.json + dependency.json
12
+ # + failed_examples.json + flaky_examples.json) to surface the
13
+ # dependency set, last-run status, and the run-decision reason.
14
+ module Explain
15
+ # @param args [Array<String>] sub-command args. First positional
16
+ # arg is the example_id (or substring) to explain.
17
+ # @param stdout [IO]
18
+ # @param stderr [IO]
19
+ # @return [Integer] exit status (0 = explanation printed,
20
+ # 1 = example not found / cache missing).
21
+ def self.run(args, stdout: $stdout, stderr: $stderr)
22
+ return print_help(stdout) if args.empty? || args.include?('-h') || args.include?('--help')
23
+
24
+ require 'rspec_tracer/load_config'
25
+ cache_path = RSpecTracer.cache_path
26
+
27
+ run_dir = resolve_run_dir(cache_path, stderr)
28
+ return 1 if run_dir.nil?
29
+
30
+ all_examples = read_json(File.join(run_dir, 'all_examples.json'))
31
+ match = find_example(all_examples, args.first)
32
+ return no_match(args.first, all_examples, stderr) if match.nil?
33
+
34
+ print_explanation(stdout, match, run_dir)
35
+ 0
36
+ rescue StandardError => e
37
+ stderr.puts "explain: #{e.class}: #{e.message}"
38
+ 1
39
+ end
40
+
41
+ # Internal helper for the tracer pipeline.
42
+ # @api private
43
+ def self.resolve_run_dir(cache_path, stderr)
44
+ last_run_path = File.join(cache_path, 'last_run.json')
45
+ unless File.file?(last_run_path)
46
+ stderr.puts "explain: no last_run.json at #{cache_path} — run rspec first"
47
+ return nil
48
+ end
49
+
50
+ run_id = JSON.parse(File.read(last_run_path, encoding: 'UTF-8'))['run_id']
51
+ run_dir = File.join(cache_path, run_id.to_s)
52
+ unless File.directory?(run_dir)
53
+ stderr.puts "explain: run_id #{run_id} directory missing at #{run_dir}"
54
+ return nil
55
+ end
56
+
57
+ run_dir
58
+ end
59
+
60
+ # Internal helper for the tracer pipeline.
61
+ # @api private
62
+ def self.no_match(query, all_examples, stderr)
63
+ stderr.puts "explain: no example matching #{query.inspect}"
64
+ stderr.puts " cache has #{all_examples.size} examples; pass an example_id or substring of description"
65
+ 1
66
+ end
67
+
68
+ # Internal helper for the tracer pipeline.
69
+ # @api private
70
+ def self.print_help(stdout)
71
+ stdout.puts <<~HELP
72
+ Usage: rspec-tracer explain <example_id_or_substring>
73
+
74
+ Show why an example is scheduled to run or skip. Matches against
75
+ example_id exactly first, then falls back to a substring match
76
+ on the example's full_description. Requires a prior rspec run.
77
+ HELP
78
+ 0
79
+ end
80
+
81
+ # Internal helper for the tracer pipeline.
82
+ # @api private
83
+ def self.read_json(path)
84
+ return {} unless File.file?(path)
85
+
86
+ parsed = JSON.parse(File.read(path, encoding: 'UTF-8'))
87
+ parsed.is_a?(Hash) ? parsed : {}
88
+ end
89
+
90
+ # Internal helper for the tracer pipeline.
91
+ # @api private
92
+ def self.find_example(all_examples, query)
93
+ return all_examples[query] if all_examples.key?(query)
94
+
95
+ all_examples.find do |id, meta|
96
+ meta = {} unless meta.is_a?(::Hash)
97
+ desc = meta['full_description'] || meta['description'] || ''
98
+ id.include?(query) || desc.include?(query)
99
+ end&.last
100
+ end
101
+
102
+ # Internal helper for the tracer pipeline.
103
+ # @api private
104
+ def self.print_explanation(stdout, meta, run_dir)
105
+ meta = {} unless meta.is_a?(::Hash)
106
+ format_lines(meta).each { |line| stdout.puts line }
107
+ print_dependency_summary(stdout, meta, run_dir)
108
+ end
109
+
110
+ # Internal helper for the tracer pipeline.
111
+ # @api private
112
+ def self.format_lines(meta)
113
+ id = first_non_nil(meta, 'example_id', 'id') || '<unknown>'
114
+ file = first_non_nil(meta, 'rerun_file_name', 'file_name')
115
+ line = first_non_nil(meta, 'rerun_line_number', 'line_number')
116
+ status = meta.dig('execution_result', 'status') || meta['status'] || 'unknown'
117
+ [
118
+ "id: #{id}",
119
+ "description: #{first_non_nil(meta, 'full_description', 'description')}",
120
+ "location: #{file}:#{line}",
121
+ "last status: #{status}",
122
+ "run reason: #{meta['run_reason'] || '<not recorded>'}"
123
+ ]
124
+ end
125
+
126
+ # Internal helper for the tracer pipeline.
127
+ # @api private
128
+ def self.first_non_nil(meta, *keys)
129
+ keys.each { |k| return meta[k] unless meta[k].nil? }
130
+ nil
131
+ end
132
+
133
+ # Internal helper for the tracer pipeline.
134
+ # @api private
135
+ def self.print_dependency_summary(stdout, meta, run_dir)
136
+ deps_path = File.join(run_dir, 'dependency.json')
137
+ return unless File.file?(deps_path)
138
+
139
+ deps = read_json(deps_path)
140
+ id = meta['example_id'] || meta['id']
141
+ files = Array(deps[id])
142
+ stdout.puts "dependencies: #{files.size} files tracked"
143
+ files.first(10).each { |f| stdout.puts " - #{f}" }
144
+ stdout.puts " ... (#{files.size - 10} more)" if files.size > 10
145
+ end
146
+ end
147
+ end
148
+ end