rspec-tracer 2.0.0.pre.1 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 04d2e3c76b8351f5f71f93cd722af23975f80da067f19970471455b3b2ed16aa
4
- data.tar.gz: 0ab86f0d744275db42ed0d355d5781d25a6f65ed49a76fcd70d3df1acd7e01b4
3
+ metadata.gz: 552b92475298cfa692f8748b7b7097b12a82a2e8b1003848d1194fe560c0b385
4
+ data.tar.gz: 34a9d94756083aaf46220460c4d10002d30992703c1aad0e417640ad5b2d79ae
5
5
  SHA512:
6
- metadata.gz: e802e4c67a41d1d1ebd16df2fd4ee874f0993678097563f38b5bd3e443024662040547df140dd45196c0e81e7d510ecfe79dbf4a3e01ab1a4685f380f9ec829f
7
- data.tar.gz: 72435a0282503ff2c90b6b80168fb810bd48412bc162043b4d70780b35f74478fc5236c43710bb9e948657b2c6c17734a59406fc617cb712eaba7e1a3c2e4dfb
6
+ metadata.gz: 57b510bf3f8da2071a72ee222d598e9ce8042b5745cec84e053ea7b7db87e203e73829f890f105d934901835813cee66290ff0b24a07f278b1821c3b75febdb7
7
+ data.tar.gz: 57adb9424e1bdc3775294de9573726f3b80bea35615d6a41410985724d930faa00c984bf3c9b637297565fa11bf72c938bb92ce8464b7968c9be9fa5b476b2f2
data/CHANGELOG.md CHANGED
@@ -1,3 +1,193 @@
1
+ ## [2.0.0.pre.2] - 2026-05-16
2
+
3
+ Bug-fix + interop release after the field-test pass that followed
4
+ `v2.0.0.pre.1`. 15 issues filed publicly
5
+ ([#182](https://github.com/avmnu-sng/rspec-tracer/issues/182)–[#196](https://github.com/avmnu-sng/rspec-tracer/issues/196))
6
+ plus two follow-on findings
7
+ ([#210](https://github.com/avmnu-sng/rspec-tracer/issues/210) and
8
+ [#218](https://github.com/avmnu-sng/rspec-tracer/issues/218))
9
+ surfaced during fix-verification; all 17 are closed at tag. No CI
10
+ surface drops, no Ruby / Rails / RSpec floor changes.
11
+
12
+ The cumulative cache `schema_version` path is `3 → 5` (two bumps
13
+ across [#209](https://github.com/avmnu-sng/rspec-tracer/pull/209)
14
+ and [#211](https://github.com/avmnu-sng/rspec-tracer/pull/211));
15
+ a pre.1 cache cold-loads cleanly on the pre.2 upgrade in one cold
16
+ run, then warm caches resume. See [`UPGRADING.md`](UPGRADING.md).
17
+
18
+ ### Added
19
+
20
+ - **`coverage_modes` config DSL** for the standalone Coverage
21
+ path (no SimpleCov). Pass any subset of
22
+ `[:lines, :branches, :methods, :oneshot_lines, :eval]`; default
23
+ `[:lines]` keeps byte-compatibility with prior runs. Threaded
24
+ through both `RSpecTracer.setup_coverage` and
25
+ `Engine#ensure_coverage_started`; inert when SimpleCov drives
26
+ Coverage. New COOKBOOK recipe "Coverage modes (rspec-tracer +
27
+ SimpleCov interop)" under recipe 9 documents the per-mode
28
+ interop matrix.
29
+ - **`bin/rspec-tracer cache:clear --force` / `-f`** as a synonym
30
+ for `--yes` / `-y`. Matches the common Unix-CLI convention.
31
+ - **COOKBOOK recipe for the `:msgpack` serializer** documenting
32
+ the `storage_backend :json, serializer: :msgpack` option for
33
+ ~3.5× smaller caches than `:json` on dependency-heavy suites.
34
+ Notes that `.msgpack.gz` payloads are raw `Zlib::Deflate`
35
+ streams (not gzip format) — the suffix is cosmetic and may
36
+ change in a future major release.
37
+
38
+ ### Changed
39
+
40
+ - **Cache `schema_version` bump 3 → 5** (cumulative). The
41
+ `example_id` digest now drops the load-order-dependent
42
+ generated-class-name suffix and the line-number fields, and
43
+ substitutes a positional discriminator for unnamed
44
+ `it { }` / `specify { }` / `example { }` examples that
45
+ previously picked up RSpec's `"example at <path>:<line>"`
46
+ description fallback. First run on pre.2 is cold; subsequent
47
+ runs return to warm. See [`UPGRADING.md`](UPGRADING.md)
48
+ "Schema-version cold runs."
49
+ - **Duplicate-example-identity detection now prune-and-continue.**
50
+ When the runner detects two examples with the same identity,
51
+ it drops the colliders from the run and lets the rest of the
52
+ suite proceed, instead of aborting the entire run to zero
53
+ examples. `fail_on_duplicates` becomes purely an exit-code
54
+ lever — the non-colliding remainder always runs. The error log
55
+ names the colliding examples (file:line + description) with a
56
+ remediation hint. See [`UPGRADING.md`](UPGRADING.md)
57
+ "Duplicate example identities."
58
+
59
+ ### Fixed
60
+
61
+ - **Restored flaky-test detection across runs.** A top-line
62
+ README feature present in 1.x since v1.0.0; silently dropped
63
+ in the 2.0 rewrite — the registry `:flaky` status, the
64
+ `:flaky_example` filter reason, the `flaky_examples` snapshot
65
+ field, and the HTML reporter's Flaky tab were all retained,
66
+ but no production code path transitioned an example into
67
+ `:flaky`. `on_example_passed` now promotes a
68
+ previously-failed-or-flaky example into `:flaky`;
69
+ `on_example_failed` keeps a previously-flaky example sticky.
70
+ Closes [#194](https://github.com/avmnu-sng/rspec-tracer/issues/194).
71
+ - **`run_reason` field in `report.json` (and the terminal
72
+ `by reason:` line) now persists the correct reason on warm
73
+ runs for every reason path** — `Failed previously`,
74
+ `Pending previously`, `Interrupted previously`, `Files changed`,
75
+ `Environment changed`. Previously displayed as `No cache` on
76
+ every warm-run case because `Engine#register_example`
77
+ short-circuited on the entry already seeded from the previous
78
+ snapshot. Single-character fix closing all five reason paths.
79
+ Closes [#186](https://github.com/avmnu-sng/rspec-tracer/issues/186).
80
+ - **Parallel-`tests` `cache_hit_reason` counts no longer inflated
81
+ by worker count.** Each worker independently computed an
82
+ identical `filtered_examples` hash against the global
83
+ previous-run snapshot; the pre-fix sum-merge inflated
84
+ always-re-run buckets N-fold. The merge now keys on
85
+ `example_id` (first-write-wins), then re-tallies. Closes
86
+ [#193](https://github.com/avmnu-sng/rspec-tracer/issues/193).
87
+ - **`example_id` stable across runs when multiple files share a
88
+ `describe` name** (the load-order-dependent
89
+ `RSpec::ExampleGroups::Name_N` disambiguator suffix is no
90
+ longer in the digest) **and stable across line-shift edits for
91
+ unnamed one-liner examples** (`it { is_expected.to eq(7) }`,
92
+ `specify { ... }`, `example { ... }`). Long-standing bugs since
93
+ v1.0.0; pervasive in shoulda-matchers model specs which are
94
+ almost entirely one-liner matcher syntax. Closes
95
+ [#196](https://github.com/avmnu-sng/rspec-tracer/issues/196)
96
+ + [#210](https://github.com/avmnu-sng/rspec-tracer/issues/210).
97
+ - **NPE in `RSpec.world.example_count` for suites with
98
+ intermediate describe groups after rspec-tracer drops a
99
+ duplicate-identity example.** Companion fix to the
100
+ duplicate-detection prune-and-continue redesign above — the
101
+ kept-map needed a default block so descendants of an
102
+ intermediate describe (a describe containing only nested
103
+ describes, no direct `it`s) resolve to an empty array on the
104
+ `filtered_examples` lookup instead of `nil`. Closes
105
+ [#218](https://github.com/avmnu-sng/rspec-tracer/issues/218).
106
+ - **`storage_backend :json, serializer: :msgpack` no longer
107
+ crashes on `Time` values** (and `Symbol` values now round-trip
108
+ losslessly across the cache). Registered
109
+ `MessagePack::Factory` type extensions for `Time` (12-byte
110
+ `tv_sec + tv_nsec`, UTC-canonicalized on decode) and `Symbol`
111
+ (UTF-8 body). Users who followed rspec-tracer's own 50 MiB
112
+ cache warning's `:msgpack` recommendation no longer brick
113
+ their cache silently on the first run that writes a `Time`.
114
+ Closes [#182](https://github.com/avmnu-sng/rspec-tracer/issues/182).
115
+ - **`bin/rspec-tracer cache:info` and `explain` now compose with
116
+ `storage_backend :sqlite`.** Previously hardcoded the
117
+ JsonBackend on-disk layout and reported `no last_run.json yet`
118
+ on every sqlite run, even after a successful rspec. Both CLI
119
+ sub-commands now dispatch through a shared
120
+ `Storage::Backend.build` factory; sqlite metadata-table reads
121
+ surface alongside JSON manifest reads behind the same
122
+ protocol. Closes
123
+ [#183](https://github.com/avmnu-sng/rspec-tracer/issues/183).
124
+ - **`bin/rspec-tracer doctor` no longer false-reports `SimpleCov:
125
+ not loaded` / `Rails: not loaded`** when those gems ARE in the
126
+ Gemfile (doctor runs in its own process; app code doesn't load
127
+ there). Three states are now reported: loaded-in-this-process
128
+ (`OK`), installed-but-not-loaded
129
+ (`INFO ... installed (<version>; not loaded in doctor's process)`),
130
+ and not-installed (`INFO ... not installed`). Closes
131
+ [#184](https://github.com/avmnu-sng/rspec-tracer/issues/184).
132
+ - **`bin/rspec-tracer` invocation guidance flipped to
133
+ `bundle exec rspec-tracer`** across README, COOKBOOK, and
134
+ UPGRADING. The bare `bin/rspec-tracer` form required users to
135
+ run `bundle binstubs rspec-tracer` first; `bundle exec
136
+ rspec-tracer` works out of the box. Closes
137
+ [#185](https://github.com/avmnu-sng/rspec-tracer/issues/185).
138
+ - **`reports_s3_path` deprecation warning no longer false-flags
139
+ on the probe path** — when no `remote_cache_backend` /
140
+ `remote_cache_uri` is configured AND the user runs
141
+ `rake rspec_tracer:remote_cache:download` / `:upload` /
142
+ `:prune_all`. A new non-warning predicate gates the probe; the
143
+ deprecation now fires only on legitimate use of the legacy
144
+ DSL. Closes
145
+ [#187](https://github.com/avmnu-sng/rspec-tracer/issues/187).
146
+ - **`remote_cache` success now emits visible INFO lines** for
147
+ `download!` (`restored cache from <ref>` — with a
148
+ `(cross-branch fallback)` qualifier when a PR-tier download
149
+ falls through to a commit-ancestry ref successfully),
150
+ `upload!` (`uploaded cache to <ref>`), and `prune_all!`
151
+ (`prune_all removed N refs`). One fix covers all three
152
+ backends (s3 / redis / file) + the cron-driven `prune_all`
153
+ admin task. Closes
154
+ [#188](https://github.com/avmnu-sng/rspec-tracer/issues/188).
155
+ - **`track_ar_schema_notifications` now installs correctly under
156
+ the canonical README setup order** (`RSpecTracer.start` BEFORE
157
+ `require_relative '../config/environment'`). Previously,
158
+ `defined?(::Rails::VERSION)` was false at engine.setup time
159
+ and the entire Rails-observer install path short-circuited —
160
+ the `sql.active_record` subscriber never attached AND the
161
+ documented `use_transactional_fixtures`-widening warn never
162
+ fired. Now late-binds via a `before(:suite)` hook that
163
+ re-checks Rails-loaded state after `rails_helper.rb` has
164
+ required the environment. Closes
165
+ [#192](https://github.com/avmnu-sng/rspec-tracer/issues/192).
166
+ - **`RSpecTracer.start` no longer crashes** when the user
167
+ pre-starts `::Coverage` (e.g. to opt into branch coverage for
168
+ SimpleCov-free runs). The `setup_coverage` entry point now
169
+ matches `Engine#ensure_coverage_started`'s `Coverage.running?`
170
+ guard + `RuntimeError` rescue. Closes
171
+ [#195](https://github.com/avmnu-sng/rspec-tracer/issues/195).
172
+ - **`InvalidUsageError` raised on conflicting
173
+ `remote_cache_backend` / `remote_cache_uri` configuration now
174
+ names both DSLs and explains they are alternatives.** The
175
+ previous `<dsl> already configured` message was confusing when
176
+ the user only typed `remote_cache_uri` (which dispatches
177
+ internally to `remote_cache_backend`).
178
+ - **README per-example-precision section now covers Rails
179
+ engines.** An engine's own `lib/` is `require`d at gem-load
180
+ time via the Gemfile.lock cascade and lands in the boot set
181
+ **regardless of `eager_load`**. COOKBOOK gains a
182
+ `transitive_load_tracking false` opt-out recipe with the
183
+ trade-off documented. Closes
184
+ [#189](https://github.com/avmnu-sng/rspec-tracer/issues/189).
185
+ - **Engine + dummy-app two-rspec-summary explainer** added to
186
+ COOKBOOK — clarifies which of the two terminal summary totals
187
+ is authoritative when an engine fixture invokes RSpec against
188
+ its dummy app. Closes
189
+ [#190](https://github.com/avmnu-sng/rspec-tracer/issues/190).
190
+
1
191
  ## [2.0.0.pre.1] - 2026-05-06
2
192
 
3
193
  The first pre-release of the 2.0 line. Architecture rewrite around
data/README.md CHANGED
@@ -48,7 +48,7 @@ Rails 8.0 needs Ruby 3.2+. JRuby 9.4 is supported.
48
48
  ```ruby
49
49
  # 2.0 is in pre-release. Pin to the pre-release version explicitly;
50
50
  # switch to '~> 2.0' once 2.0.0 final ships.
51
- gem 'rspec-tracer', '= 2.0.0.pre.1', group: :test, require: false
51
+ gem 'rspec-tracer', '= 2.0.0.pre.2', group: :test, require: false
52
52
  ```
53
53
 
54
54
  `bundle install` will resolve the pre-release version. You can
@@ -144,6 +144,17 @@ eager-loaded test environments — see
144
144
  [`CHANGELOG.md`](CHANGELOG.md) "Deferred to 2.1" for the planned
145
145
  contract.
146
146
 
147
+ **Rails engines:** a gem-loaded engine's own `lib/` files are
148
+ `require`d at gem-load time via the Gemfile.lock cascade and land
149
+ in the boot set **regardless of `eager_load`**. Editing them
150
+ re-runs every example — same SAFE-but-coarser shape as the
151
+ `eager_load = true` case above. The rationale (closing the
152
+ constants-lookup blind spot) lives in
153
+ [`lib/rspec_tracer/tracker/loaded_files_tracker.rb`](lib/rspec_tracer/tracker/loaded_files_tracker.rb).
154
+ Teams that want tighter per-example precision and accept the blind
155
+ spot can set `transitive_load_tracking false` — see
156
+ [`COOKBOOK.md`](COOKBOOK.md) recipe 2 for the trade-off.
157
+
147
158
  ## Per-example `tracks:` DSL
148
159
 
149
160
  Annotate any describe / context / example with extra inputs the
@@ -365,19 +376,23 @@ Or override per-run via env: `RSPEC_TRACER_STORAGE=sqlite`.
365
376
 
366
377
  ## Command-line tools
367
378
 
368
- `bin/rspec-tracer` exposes five sub-commands:
379
+ `rspec-tracer` exposes five sub-commands. Run them via Bundler so the
380
+ gem's executable resolves cleanly without needing `bundle binstubs
381
+ rspec-tracer` first:
369
382
 
370
383
  ```sh
371
- bin/rspec-tracer doctor # diagnose config + environment
372
- bin/rspec-tracer cache:info # size, last run, invalidation stats
373
- bin/rspec-tracer cache:clear # rm cache dirs
374
- bin/rspec-tracer report:open # open the HTML report
375
- bin/rspec-tracer explain <id> # why is <example_id> scheduled to (re-)run?
384
+ bundle exec rspec-tracer doctor # diagnose config + environment
385
+ bundle exec rspec-tracer cache:info # size, last run, invalidation stats
386
+ bundle exec rspec-tracer cache:clear # rm cache dirs
387
+ bundle exec rspec-tracer report:open # open the HTML report
388
+ bundle exec rspec-tracer explain <id> # why is <example_id> scheduled to (re-)run?
376
389
  ```
377
390
 
378
- The CLI is opt-in for local-dev convenience. The
379
- `rake rspec_tracer:remote_cache:*` tasks remain first-class for CI
380
- integration nothing in the CLI replaces them.
391
+ Generated binstubs (`bin/rspec-tracer …`) work too once you've run
392
+ `bundle binstubs rspec-tracer` in your project. The CLI is opt-in for
393
+ local-dev convenience; the `rake rspec_tracer:remote_cache:*` tasks
394
+ remain first-class for CI integration — nothing in the CLI replaces
395
+ them.
381
396
 
382
397
  ## SimpleCov interop
383
398
 
@@ -23,8 +23,7 @@ module RSpecTracer
23
23
  return nothing_to_remove(stdout) if existing.empty?
24
24
 
25
25
  announce(stdout, existing)
26
- force = args.include?('--yes') || args.include?('-y')
27
- return aborted(stdout) unless force || confirm?(stdout)
26
+ return aborted(stdout) unless skip_confirmation?(args) || confirm?(stdout)
28
27
 
29
28
  remove_each(stdout, existing)
30
29
  0
@@ -33,6 +32,19 @@ module RSpecTracer
33
32
  1
34
33
  end
35
34
 
35
+ # Returns true when any of the documented skip-confirmation
36
+ # flags is present. `--yes` / `-y` is the canonical form;
37
+ # `--force` / `-f` is the Unix-conventional synonym accepted
38
+ # so users' muscle memory works. Either form skips the
39
+ # interactive `Proceed? [y/N]` prompt.
40
+ # @api private
41
+ SKIP_CONFIRMATION_FLAGS = %w[--yes -y --force -f].freeze
42
+
43
+ # @api private
44
+ def self.skip_confirmation?(args)
45
+ args.any? { |arg| SKIP_CONFIRMATION_FLAGS.include?(arg) }
46
+ end
47
+
36
48
  # Internal helper for the tracer pipeline.
37
49
  # @api private
38
50
  def self.existing_targets
@@ -83,13 +95,14 @@ module RSpecTracer
83
95
  # @api private
84
96
  def self.print_help(stdout)
85
97
  stdout.puts <<~HELP
86
- Usage: rspec-tracer cache:clear [--yes]
98
+ Usage: rspec-tracer cache:clear [--yes | --force]
87
99
 
88
100
  Remove cache, coverage, and report directories. The next rspec
89
101
  run will be a cold run (full re-execution + cache rebuild).
90
102
 
91
103
  Options:
92
- -y, --yes Skip the confirmation prompt.
104
+ -y, --yes Skip the confirmation prompt.
105
+ -f, --force Synonym for --yes.
93
106
  HELP
94
107
  0
95
108
  end
@@ -1,14 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'json'
3
+ require 'rspec_tracer/storage/backend'
4
+ require 'rspec_tracer/storage/json_backend'
5
+ require 'rspec_tracer/storage/schema'
6
+ require 'rspec_tracer/storage/sqlite_backend' if RUBY_ENGINE == 'ruby'
4
7
 
5
8
  module RSpecTracer
6
9
  # Internal CLI — see {RSpecTracer} for the user-facing surface.
7
10
  # @api private
8
11
  module CLI
9
12
  # `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.
13
+ # invalidation stats. Backend-agnostic: dispatches through
14
+ # {RSpecTracer::Storage::Backend.build} so `storage_backend
15
+ # :sqlite` reports the populated cache instead of the false
16
+ # "no cache yet" the JsonBackend-only path used to emit.
12
17
  module CacheInfo
13
18
  # @param args [Array<String>] sub-command args (`-h` / `--help`).
14
19
  # @param stdout [IO]
@@ -23,18 +28,15 @@ module RSpecTracer
23
28
  stdout.puts "cache_path: #{cache_path}"
24
29
  stdout.puts "size: #{format_bytes(directory_size(cache_path))}"
25
30
 
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)'
31
+ backend = Storage::Backend.build(cache_path: cache_path, configuration: RSpecTracer)
32
+ run_id = backend.last_run_id
33
+ if run_id.nil? || run_id.to_s.empty?
34
+ stdout.puts 'last_run: no cache yet (run rspec first)'
29
35
  return 0
30
36
  end
31
37
 
32
- manifest = JSON.parse(File.read(last_run_path, encoding: 'UTF-8'))
33
- run_id = manifest['run_id']
34
38
  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
39
+ print_example_count(stdout, backend)
38
40
  0
39
41
  rescue StandardError => e
40
42
  stderr.puts "cache:info: #{e.class}: #{e.message}"
@@ -48,24 +50,23 @@ module RSpecTracer
48
50
  Usage: rspec-tracer cache:info
49
51
 
50
52
  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
+ for the most recent run. Backend-aware: works under
54
+ `storage_backend :json` (default) and `storage_backend :sqlite`.
55
+ Read-only; does not modify the cache.
53
56
  HELP
54
57
  0
55
58
  end
56
59
 
57
60
  # Internal helper for the tracer pipeline.
58
61
  # @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)
62
+ def self.print_example_count(stdout, backend)
63
+ snapshot = backend.load_graph(schema_version: Storage::Schema::CURRENT)
64
+ if snapshot.nil?
65
+ stdout.puts 'examples: <unknown> (schema mismatch; next rspec run will be cold)'
66
+ return
67
+ end
65
68
 
66
- data = JSON.parse(File.read(all_examples_path, encoding: 'UTF-8'))
67
- total = data.size
68
- stdout.puts "examples: #{total} tracked"
69
+ stdout.puts "examples: #{snapshot.all_examples.size} tracked"
69
70
  end
70
71
 
71
72
  # Internal helper for the tracer pipeline.
@@ -109,24 +109,33 @@ module RSpecTracer
109
109
  end
110
110
  end
111
111
 
112
- # Internal helper for the tracer pipeline.
113
- # @api private
112
+ # `bundle exec rspec-tracer doctor` runs in its own process via
113
+ # the gem's `bin/rspec-tracer` binstub, NOT inside the user's
114
+ # rspec boot — so app code never loads here and a bare
115
+ # `defined?(::SimpleCov)` check would falsely report "not
116
+ # loaded" on projects that DO have SimpleCov in their
117
+ # Gemfile. Probe `Gem.loaded_specs` first to surface the
118
+ # "installed but not loaded in doctor's process" case
119
+ # separately from "actually not installed."
114
120
  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
121
+ return 'OK SimpleCov: loaded (interop active)' if defined?(::SimpleCov)
122
+
123
+ spec = Gem.loaded_specs['simplecov']
124
+ return "INFO SimpleCov: installed (v#{spec.version}; not loaded in doctor's process)" if spec
125
+
126
+ 'INFO SimpleCov: not installed (this is fine; SimpleCov is optional)'
120
127
  end
121
128
 
122
- # Internal helper for the tracer pipeline.
123
- # @api private
129
+ # See {.simplecov_check} for the doctor-runs-in-its-own-
130
+ # process rationale. Same three-state probe shape: loaded in
131
+ # this process / installed but not loaded / not installed.
124
132
  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
133
+ return "OK Rails: #{::Rails::VERSION::STRING}" if defined?(::Rails::VERSION) && !::Rails::VERSION.nil?
134
+
135
+ spec = Gem.loaded_specs['rails']
136
+ return "INFO Rails: installed (v#{spec.version}; not loaded in doctor's process)" if spec
137
+
138
+ 'INFO Rails: not installed (this is fine for non-Rails projects)'
130
139
  end
131
140
 
132
141
  # Surface a 1.x->2.0 cache mismatch BEFORE the user runs
@@ -1,16 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'json'
3
+ require 'rspec_tracer/storage/backend'
4
+ require 'rspec_tracer/storage/json_backend'
5
+ require 'rspec_tracer/storage/schema'
6
+ require 'rspec_tracer/storage/sqlite_backend' if RUBY_ENGINE == 'ruby'
4
7
 
5
8
  module RSpecTracer
6
9
  # Internal CLI — see {RSpecTracer} for the user-facing surface.
7
10
  # @api private
8
11
  module CLI
9
12
  # `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.
13
+ # scheduled to run or skip on the next rspec invocation. Backend-
14
+ # agnostic: dispatches through {RSpecTracer::Storage::Backend.build}
15
+ # so `storage_backend :sqlite` resolves the latest run from the
16
+ # meta table instead of the JsonBackend-only `last_run.json` file.
14
17
  module Explain
15
18
  # @param args [Array<String>] sub-command args. First positional
16
19
  # arg is the example_id (or substring) to explain.
@@ -24,14 +27,13 @@ module RSpecTracer
24
27
  require 'rspec_tracer/load_config'
25
28
  cache_path = RSpecTracer.cache_path
26
29
 
27
- run_dir = resolve_run_dir(cache_path, stderr)
28
- return 1 if run_dir.nil?
30
+ snapshot = load_snapshot(cache_path, stderr)
31
+ return 1 if snapshot.nil?
29
32
 
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
+ match = find_example(snapshot.all_examples, args.first)
34
+ return no_match(args.first, snapshot.all_examples, stderr) if match.nil?
33
35
 
34
- print_explanation(stdout, match, run_dir)
36
+ print_explanation(stdout, match, snapshot)
35
37
  0
36
38
  rescue StandardError => e
37
39
  stderr.puts "explain: #{e.class}: #{e.message}"
@@ -40,21 +42,21 @@ module RSpecTracer
40
42
 
41
43
  # Internal helper for the tracer pipeline.
42
44
  # @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"
45
+ def self.load_snapshot(cache_path, stderr)
46
+ backend = Storage::Backend.build(cache_path: cache_path, configuration: RSpecTracer)
47
+ run_id = backend.last_run_id
48
+ if run_id.nil? || run_id.to_s.empty?
49
+ stderr.puts "explain: no cache yet at #{cache_path} — run rspec first"
47
50
  return nil
48
51
  end
49
52
 
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}"
53
+ snapshot = backend.load_graph(schema_version: Storage::Schema::CURRENT)
54
+ if snapshot.nil?
55
+ stderr.puts "explain: cache at #{cache_path} is incompatible with this rspec-tracer; next rspec run is cold"
54
56
  return nil
55
57
  end
56
58
 
57
- run_dir
59
+ snapshot
58
60
  end
59
61
 
60
62
  # Internal helper for the tracer pipeline.
@@ -73,20 +75,13 @@ module RSpecTracer
73
75
 
74
76
  Show why an example is scheduled to run or skip. Matches against
75
77
  example_id exactly first, then falls back to a substring match
76
- on the example's full_description. Requires a prior rspec run.
78
+ on the example's full_description. Backend-aware: works under
79
+ `storage_backend :json` (default) and `storage_backend :sqlite`.
80
+ Requires a prior rspec run.
77
81
  HELP
78
82
  0
79
83
  end
80
84
 
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
85
  # Internal helper for the tracer pipeline.
91
86
  # @api private
92
87
  def self.find_example(all_examples, query)
@@ -94,50 +89,65 @@ module RSpecTracer
94
89
 
95
90
  all_examples.find do |id, meta|
96
91
  meta = {} unless meta.is_a?(::Hash)
97
- desc = meta['full_description'] || meta['description'] || ''
92
+ desc = fetch_meta(meta, 'full_description') || fetch_meta(meta, 'description') || ''
98
93
  id.include?(query) || desc.include?(query)
99
94
  end&.last
100
95
  end
101
96
 
102
97
  # Internal helper for the tracer pipeline.
103
98
  # @api private
104
- def self.print_explanation(stdout, meta, run_dir)
99
+ def self.print_explanation(stdout, meta, snapshot)
105
100
  meta = {} unless meta.is_a?(::Hash)
106
101
  format_lines(meta).each { |line| stdout.puts line }
107
- print_dependency_summary(stdout, meta, run_dir)
102
+ print_dependency_summary(stdout, meta, snapshot)
108
103
  end
109
104
 
110
105
  # Internal helper for the tracer pipeline.
111
106
  # @api private
112
107
  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'
108
+ id = fetch_meta(meta, 'example_id', 'id') || '<unknown>'
109
+ file = fetch_meta(meta, 'rerun_file_name', 'file_name')
110
+ line = fetch_meta(meta, 'rerun_line_number', 'line_number')
111
+ status = dig_meta(meta, 'execution_result', 'status') || fetch_meta(meta, 'status') || 'unknown'
117
112
  [
118
113
  "id: #{id}",
119
- "description: #{first_non_nil(meta, 'full_description', 'description')}",
114
+ "description: #{fetch_meta(meta, 'full_description', 'description')}",
120
115
  "location: #{file}:#{line}",
121
116
  "last status: #{status}",
122
- "run reason: #{meta['run_reason'] || '<not recorded>'}"
117
+ "run reason: #{fetch_meta(meta, 'run_reason') || '<not recorded>'}"
123
118
  ]
124
119
  end
125
120
 
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? }
121
+ # Look up a key from a Hash, tolerating both String and Symbol
122
+ # storage. Snapshot Hashes round-tripped through JSON yield
123
+ # String keys; the post-#182 msgpack serializer preserves
124
+ # Symbol keys end-to-end, so callers can't assume either shape.
125
+ def self.fetch_meta(meta, *keys)
126
+ keys.each do |k|
127
+ v = meta[k]
128
+ return v unless v.nil?
129
+
130
+ sym_value = meta[k.to_sym]
131
+ return sym_value unless sym_value.nil?
132
+ end
130
133
  nil
131
134
  end
132
135
 
136
+ # Look up a nested key from a Hash, tolerating both String and
137
+ # Symbol storage at each level. See {.fetch_meta} for rationale.
138
+ def self.dig_meta(meta, *keys)
139
+ keys.reduce(meta) do |acc, k|
140
+ break nil if acc.nil? || !acc.is_a?(::Hash)
141
+
142
+ acc[k] || acc[k.to_sym]
143
+ end
144
+ end
145
+
133
146
  # Internal helper for the tracer pipeline.
134
147
  # @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']
148
+ def self.print_dependency_summary(stdout, meta, snapshot)
149
+ id = fetch_meta(meta, 'example_id', 'id')
150
+ deps = snapshot.dependency || {}
141
151
  files = Array(deps[id])
142
152
  stdout.puts "dependencies: #{files.size} files tracked"
143
153
  files.first(10).each { |f| stdout.puts " - #{f}" }