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
@@ -0,0 +1,686 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+ require 'set'
6
+ require 'time'
7
+
8
+ require_relative 'backend'
9
+ require_relative 'lazy_snapshot'
10
+ require_relative 'schema'
11
+ require_relative 'snapshot'
12
+
13
+ module RSpecTracer
14
+ # Internal Storage — see {RSpecTracer} for the user-facing surface.
15
+ # @api private
16
+ module Storage
17
+ # SQLite-on-disk storage backend. Single file
18
+ # `cache_path/rspec_tracer.sqlite3` with a normalized 9-table
19
+ # schema so a warm run can materialize only the rows a given
20
+ # field needs (Filter.select hits ~50-500 example_ids out of
21
+ # millions, JsonBackend eagerly read the whole map). Breaks
22
+ # the RAM-scales-with-cache-size curve that JsonBackend cannot
23
+ # escape without a different on-disk shape.
24
+ #
25
+ # MRI-only. The `sqlite3` gem targets MRI's C API; JRuby's
26
+ # `jdbc-sqlite3` has a different API that this backend does
27
+ # not target in 2.0. Users who select `:sqlite` on JRuby
28
+ # (or on MRI without the `sqlite3` gem in their Gemfile) see
29
+ # the construct raise `SqliteBackendError`, which the Engine's
30
+ # backend dispatch converts to a warn + cold-run fallback.
31
+ #
32
+ # No multi-run history. SqliteBackend stores only the latest
33
+ # run; save_graph full-replaces every table inside a single
34
+ # `BEGIN IMMEDIATE` transaction. JsonBackend's
35
+ # `cache_retention_local_count` is a no-op here. Users who
36
+ # need rollback history stay on `:json`.
37
+ #
38
+ # `journal_mode = MEMORY` so SQLite's WAL / SHM sidecars do not
39
+ # leak into the user-facing `rspec_tracer_cache/` directory
40
+ # (USER_FACING_SURFACE.md section 6 locks the layout; sidecars
41
+ # would surprise debug scripts that walk the dir expecting only
42
+ # documented files).
43
+ # rubocop:disable Metrics/ClassLength
44
+ class SqliteBackend
45
+ # Raised when the sqlite3 gem cannot be loaded. Engine's
46
+ # build_storage_backend dispatch rescues + falls back to
47
+ # `:json` with a warn line - same optional-dep posture as
48
+ # RedisBackend uses for the redis gem.
49
+ class SqliteBackendError < StandardError; end
50
+
51
+ # Internal constant.
52
+ # @api private
53
+ DB_FILENAME = 'rspec_tracer.sqlite3'
54
+ # Internal constant.
55
+ # @api private
56
+ JOURNAL_MODE_SQL = 'PRAGMA journal_mode = MEMORY'
57
+ # Internal constant.
58
+ # @api private
59
+ SYNCHRONOUS_SQL = 'PRAGMA synchronous = NORMAL'
60
+
61
+ # Two concurrent save_graph calls (parallel_tests workers, fork-
62
+ # based test harnesses) both reach `BEGIN IMMEDIATE` and contend
63
+ # on SQLite's RESERVED write lock. Without busy_timeout, the
64
+ # losing writer raises SQLite3::BusyException immediately. 5000
65
+ # ms gives ~5x margin over a worst-case 1 s save on large caches
66
+ # (cache_load benchmark p50 ~0.6 s at 500 examples), preserving
67
+ # the storage layer's "concurrent writers serialize cleanly"
68
+ # contract verified in spec/edge_cases/concurrent_write_spec.rb.
69
+ BUSY_TIMEOUT_MS = 5_000
70
+
71
+ # SQLite's on-disk file format starts with the literal bytes
72
+ # `SQLite format 3\000` (16 bytes). A file under db_path whose
73
+ # leading bytes differ is not a valid database - corrupted bytes,
74
+ # zero-length file, or a foreign file collision. Per the
75
+ # architecture's graceful-degradation rule (load_graph never
76
+ # raises; finalize writes a fresh cache), save_graph self-heals
77
+ # by deleting the corrupt file before opening, so the subsequent
78
+ # Database.new + ensure_schema! sequence writes a fresh db.
79
+ # Without this, configure_connection's PRAGMA raises
80
+ # SQLite3::NotADatabaseException and the user is stuck unless
81
+ # they manually rm the cache. The `cache_loader_fuzz` harness
82
+ # surfaced this gap.
83
+ SQLITE_MAGIC_BYTES = "SQLite format 3\x00".b.freeze
84
+ private_constant :SQLITE_MAGIC_BYTES
85
+
86
+ # Internal constant.
87
+ # @api private
88
+ STATUS_FIELDS = {
89
+ interrupted_examples: 'interrupted',
90
+ flaky_examples: 'flaky',
91
+ failed_examples: 'failed',
92
+ pending_examples: 'pending',
93
+ skipped_examples: 'skipped'
94
+ }.freeze
95
+
96
+ # Internal constant.
97
+ # @api private
98
+ DIGEST_MAP_KINDS = {
99
+ boot_set: 'boot',
100
+ wsi_snapshot: 'wsi',
101
+ env_snapshot: 'env'
102
+ }.freeze
103
+
104
+ # Mirrors FIELD_FILENAMES from JsonBackend - the set of Snapshot
105
+ # fields a LazySnapshot reader may ask about. Used by
106
+ # SqliteFieldReader to reject unknown fields before hitting the
107
+ # DB. Kept in step with Snapshot.members minus the envelope.
108
+ READABLE_FIELDS = (
109
+ %i[all_examples duplicate_examples all_files dependency
110
+ reverse_dependency examples_coverage env_dependency cache_hit_reason] +
111
+ STATUS_FIELDS.keys + DIGEST_MAP_KINDS.keys
112
+ ).freeze
113
+
114
+ # Binds a SqliteBackend so LazySnapshot readers can dispatch
115
+ # one field at a time without threading a connection through.
116
+ class SqliteFieldReader
117
+ # Internal method on the tracer pipeline.
118
+ # @api private
119
+ def initialize(backend:)
120
+ @backend = backend
121
+ end
122
+
123
+ # Internal method on the tracer pipeline.
124
+ # @api private
125
+ def read(field)
126
+ @backend.read_field(field)
127
+ end
128
+ end
129
+
130
+ # Internal attribute.
131
+ # @api private
132
+ attr_reader :cache_path
133
+
134
+ # Internal method on the tracer pipeline.
135
+ # @api private
136
+ def initialize(cache_path:, logger: nil)
137
+ @cache_path = File.expand_path(cache_path)
138
+ @logger = logger
139
+ load_sqlite_driver!
140
+ end
141
+
142
+ # Internal method on the tracer pipeline.
143
+ # @api private
144
+ def last_run_id
145
+ with_connection do |db|
146
+ row = db.get_first_row('SELECT run_id FROM meta LIMIT 1')
147
+ run_id = row&.first
148
+ return nil if run_id.nil? || run_id.to_s.empty?
149
+
150
+ run_id
151
+ end
152
+ rescue StandardError
153
+ nil
154
+ end
155
+
156
+ # Internal method on the tracer pipeline.
157
+ # @api private
158
+ def load_graph(schema_version:)
159
+ with_connection do |db|
160
+ return nil unless meta_table_exists?(db)
161
+
162
+ row = db.get_first_row('SELECT schema_version, run_id FROM meta LIMIT 1')
163
+ return nil if row.nil?
164
+
165
+ stored_sv, run_id = row
166
+ unless Schema.supported?(stored_sv) && stored_sv == schema_version
167
+ info("schema_version mismatch (stored=#{stored_sv.inspect}, expected=#{schema_version}); cold run")
168
+ return nil
169
+ end
170
+ return nil if run_id.nil? || run_id.to_s.empty?
171
+
172
+ LazySnapshot.new(
173
+ schema_version: stored_sv, run_id: run_id,
174
+ reader: SqliteFieldReader.new(backend: self)
175
+ )
176
+ end
177
+ rescue StandardError => e
178
+ info("failed to load sqlite cache: #{e.class}: #{e.message}; cold run")
179
+ nil
180
+ end
181
+
182
+ # Internal method on the tracer pipeline.
183
+ # @api private
184
+ def save_graph(snapshot, schema_version:)
185
+ raise ArgumentError, 'snapshot must not be nil' if snapshot.nil?
186
+
187
+ unless Schema.supported?(schema_version)
188
+ raise ArgumentError, "unsupported schema_version: #{schema_version.inspect}"
189
+ end
190
+
191
+ run_id = snapshot.run_id
192
+ raise ArgumentError, 'snapshot.run_id must be a non-empty string' if run_id.nil? || run_id.to_s.empty?
193
+
194
+ transactional_save do
195
+ db = @active_db
196
+ write_all_tables(db, snapshot, schema_version: schema_version)
197
+ end
198
+
199
+ snapshot
200
+ end
201
+
202
+ # Internal method on the tracer pipeline.
203
+ # @api private
204
+ def clear!
205
+ return unless File.directory?(@cache_path)
206
+
207
+ FileUtils.rm_rf(@cache_path)
208
+ end
209
+
210
+ # Internal method on the tracer pipeline.
211
+ # @api private
212
+ def transactional_save(&block)
213
+ raise ArgumentError, 'block required' unless block
214
+
215
+ FileUtils.mkdir_p(@cache_path)
216
+ with_write_connection do |db|
217
+ @active_db = db
218
+ db.transaction(:immediate, &block)
219
+ ensure
220
+ @active_db = nil
221
+ end
222
+ end
223
+
224
+ # Read one field on behalf of a LazySnapshot. Missing table or
225
+ # empty result returns the per-field empty default (Set for
226
+ # id-set fields, {} for hashes) so partial caches behave
227
+ # identically to JsonBackend under the same conditions.
228
+ # ArgumentError on an unknown field is a programmer mistake and
229
+ # propagates; the StandardError rescue is only for wire / I/O
230
+ # failures on the DB itself.
231
+ def read_field(field)
232
+ raise ArgumentError, "unknown snapshot field: #{field.inspect}" unless READABLE_FIELDS.include?(field)
233
+
234
+ begin
235
+ with_connection { |db| dispatch_read(db, field) }
236
+ rescue StandardError => e
237
+ info("sqlite read_field(#{field.inspect}) failed: #{e.class}: #{e.message}; returning empty default")
238
+ empty_default_for(field)
239
+ end
240
+ end
241
+
242
+ private
243
+
244
+ # Internal method on the tracer pipeline.
245
+ # @api private
246
+ def db_path
247
+ File.join(@cache_path, DB_FILENAME)
248
+ end
249
+
250
+ # Internal method on the tracer pipeline.
251
+ # @api private
252
+ def load_sqlite_driver!
253
+ require 'sqlite3'
254
+ rescue LoadError
255
+ raise SqliteBackendError,
256
+ "sqlite3 gem is not installed; add `gem 'sqlite3'` to your Gemfile " \
257
+ '(MRI only; pin `~> 1.7` on Ruby 3.1 or `~> 2.0` on Ruby >= 3.2) ' \
258
+ 'or switch to `storage_backend :json`.'
259
+ end
260
+
261
+ # Internal method on the tracer pipeline.
262
+ # @api private
263
+ def with_connection
264
+ return nil unless File.file?(db_path)
265
+
266
+ db = ::SQLite3::Database.new(db_path)
267
+ configure_connection(db)
268
+ yield db
269
+ ensure
270
+ db&.close
271
+ end
272
+
273
+ # Internal method on the tracer pipeline.
274
+ # @api private
275
+ def with_write_connection
276
+ FileUtils.mkdir_p(@cache_path)
277
+ reset_corrupt_db_file!
278
+ db = ::SQLite3::Database.new(db_path)
279
+ configure_connection(db)
280
+ ensure_schema!(db)
281
+ yield db
282
+ ensure
283
+ db&.close
284
+ end
285
+
286
+ # Probe db_path's leading bytes against SQLite's magic header.
287
+ # Random bytes / zero-length / foreign collision -> delete so
288
+ # save_graph can write fresh. Cheap (one 16-byte read); avoids
289
+ # the SQLite3::NotADatabaseException path on configure_connection.
290
+ def reset_corrupt_db_file!
291
+ return unless File.file?(db_path)
292
+ return if File.size(db_path) >= SQLITE_MAGIC_BYTES.bytesize &&
293
+ File.binread(db_path, SQLITE_MAGIC_BYTES.bytesize) == SQLITE_MAGIC_BYTES
294
+
295
+ info("sqlite: cache file at #{db_path} is not a valid SQLite database; resetting for cold-cache rewrite")
296
+ File.delete(db_path)
297
+ end
298
+
299
+ # Internal method on the tracer pipeline.
300
+ # @api private
301
+ def configure_connection(db)
302
+ # Set busy_timeout FIRST. journal_mode = MEMORY itself requires
303
+ # the file's write lock to switch modes; if another connection is
304
+ # mid-transaction (BEGIN IMMEDIATE held), the PRAGMA contends on
305
+ # that lock and raises SQLite3::BusyException unless busy_timeout
306
+ # has been configured. CI on Ruby 3.3 surfaced this: the
307
+ # losing concurrent writer never reached its BEGIN IMMEDIATE
308
+ # because configure_connection's PRAGMA failed first.
309
+ db.busy_timeout = BUSY_TIMEOUT_MS
310
+ db.execute(JOURNAL_MODE_SQL)
311
+ db.execute(SYNCHRONOUS_SQL)
312
+ end
313
+
314
+ # Internal method on the tracer pipeline.
315
+ # @api private
316
+ def meta_table_exists?(db)
317
+ row = db.get_first_row(
318
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'meta' LIMIT 1"
319
+ )
320
+ !row.nil?
321
+ end
322
+
323
+ # Internal method on the tracer pipeline.
324
+ # @api private
325
+ def ensure_schema!(db)
326
+ SCHEMA_STATEMENTS.each { |sql| db.execute(sql) }
327
+ end
328
+
329
+ # Internal constant.
330
+ # @api private
331
+ SCHEMA_STATEMENTS = [
332
+ 'CREATE TABLE IF NOT EXISTS meta (' \
333
+ 'schema_version INTEGER NOT NULL, ' \
334
+ 'run_id TEXT NOT NULL, ' \
335
+ 'timestamp TEXT NOT NULL' \
336
+ ')',
337
+ 'CREATE TABLE IF NOT EXISTS examples (' \
338
+ 'example_id TEXT PRIMARY KEY, ' \
339
+ 'metadata_json BLOB NOT NULL' \
340
+ ')',
341
+ 'CREATE TABLE IF NOT EXISTS duplicate_examples (' \
342
+ 'example_id TEXT NOT NULL, ' \
343
+ 'idx INTEGER NOT NULL, ' \
344
+ 'entry_json BLOB NOT NULL, ' \
345
+ 'PRIMARY KEY (example_id, idx)' \
346
+ ')',
347
+ 'CREATE TABLE IF NOT EXISTS all_files (' \
348
+ 'file_name TEXT PRIMARY KEY, ' \
349
+ 'file_path TEXT NOT NULL, ' \
350
+ 'digest TEXT NOT NULL' \
351
+ ')',
352
+ 'CREATE TABLE IF NOT EXISTS dependency (' \
353
+ 'example_id TEXT NOT NULL, ' \
354
+ 'file_name TEXT NOT NULL, ' \
355
+ 'PRIMARY KEY (example_id, file_name)' \
356
+ ')',
357
+ 'CREATE INDEX IF NOT EXISTS idx_dependency_file_name ON dependency(file_name)',
358
+ 'CREATE TABLE IF NOT EXISTS examples_coverage (' \
359
+ 'example_id TEXT NOT NULL, ' \
360
+ 'file_path TEXT NOT NULL, ' \
361
+ 'line_key INTEGER NOT NULL, ' \
362
+ 'strength INTEGER NOT NULL, ' \
363
+ 'PRIMARY KEY (example_id, file_path, line_key)' \
364
+ ')',
365
+ 'CREATE TABLE IF NOT EXISTS env_dependency (' \
366
+ 'example_id TEXT NOT NULL, ' \
367
+ 'env_name TEXT NOT NULL, ' \
368
+ 'PRIMARY KEY (example_id, env_name)' \
369
+ ')',
370
+ 'CREATE TABLE IF NOT EXISTS digest_maps (' \
371
+ 'kind TEXT NOT NULL, ' \
372
+ 'key TEXT NOT NULL, ' \
373
+ 'digest TEXT NOT NULL, ' \
374
+ 'PRIMARY KEY (kind, key)' \
375
+ ')',
376
+ 'CREATE TABLE IF NOT EXISTS id_sets (' \
377
+ 'status TEXT NOT NULL, ' \
378
+ 'example_id TEXT NOT NULL, ' \
379
+ 'PRIMARY KEY (status, example_id)' \
380
+ ')'
381
+ ].freeze
382
+ private_constant :SCHEMA_STATEMENTS
383
+
384
+ # Internal constant.
385
+ # @api private
386
+ TRUNCATE_TABLES = %w[
387
+ meta examples duplicate_examples all_files dependency
388
+ examples_coverage env_dependency digest_maps id_sets
389
+ ].freeze
390
+ private_constant :TRUNCATE_TABLES
391
+
392
+ # Internal method on the tracer pipeline.
393
+ # @api private
394
+ def write_all_tables(db, snapshot, schema_version:)
395
+ TRUNCATE_TABLES.each { |t| db.execute("DELETE FROM #{t}") }
396
+
397
+ db.execute(
398
+ 'INSERT INTO meta (schema_version, run_id, timestamp) VALUES (?, ?, ?)',
399
+ [schema_version, snapshot.run_id, Time.now.utc.iso8601]
400
+ )
401
+
402
+ write_example_rows(db, snapshot)
403
+ write_graph_rows(db, snapshot)
404
+ write_grouped_rows(db, snapshot)
405
+ end
406
+
407
+ # Internal method on the tracer pipeline.
408
+ # @api private
409
+ def write_example_rows(db, snapshot)
410
+ insert_examples(db, snapshot.all_examples || {})
411
+ insert_duplicate_examples(db, snapshot.duplicate_examples || {})
412
+ insert_all_files(db, snapshot.all_files || {})
413
+ end
414
+
415
+ # Internal method on the tracer pipeline.
416
+ # @api private
417
+ def write_graph_rows(db, snapshot)
418
+ insert_dependency(db, snapshot.dependency || {})
419
+ insert_examples_coverage(db, snapshot.examples_coverage || {})
420
+ insert_env_dependency(db, snapshot.env_dependency || {})
421
+ end
422
+
423
+ # Internal method on the tracer pipeline.
424
+ # @api private
425
+ def write_grouped_rows(db, snapshot)
426
+ DIGEST_MAP_KINDS.each do |field, kind|
427
+ insert_digest_map(db, kind, snapshot.send(field) || {})
428
+ end
429
+ STATUS_FIELDS.each do |field, status|
430
+ insert_id_set(db, status, snapshot.send(field) || Set.new)
431
+ end
432
+ end
433
+
434
+ # Internal method on the tracer pipeline.
435
+ # @api private
436
+ def insert_examples(db, examples)
437
+ sql = 'INSERT INTO examples (example_id, metadata_json) VALUES (?, ?)'
438
+ examples.each { |id, meta| db.execute(sql, [id.to_s, ::JSON.generate(meta || {})]) }
439
+ end
440
+
441
+ # Internal method on the tracer pipeline.
442
+ # @api private
443
+ def insert_duplicate_examples(db, dupes)
444
+ sql = 'INSERT INTO duplicate_examples (example_id, idx, entry_json) VALUES (?, ?, ?)'
445
+ dupes.each do |id, list|
446
+ Array(list).each_with_index do |entry, idx|
447
+ db.execute(sql, [id.to_s, idx, ::JSON.generate(entry || {})])
448
+ end
449
+ end
450
+ end
451
+
452
+ # Internal method on the tracer pipeline.
453
+ # @api private
454
+ def insert_all_files(db, all_files)
455
+ sql = 'INSERT INTO all_files (file_name, file_path, digest) VALUES (?, ?, ?)'
456
+ all_files.each do |file_name, meta|
457
+ next unless meta.is_a?(Hash)
458
+
459
+ db.execute(sql, [file_name.to_s, fetch_hash(meta, :file_path).to_s, fetch_hash(meta, :digest).to_s])
460
+ end
461
+ end
462
+
463
+ # Internal method on the tracer pipeline.
464
+ # @api private
465
+ def insert_dependency(db, dependency)
466
+ sql = 'INSERT INTO dependency (example_id, file_name) VALUES (?, ?)'
467
+ dependency.each do |id, file_names|
468
+ Array(file_names).each { |name| db.execute(sql, [id.to_s, name.to_s]) }
469
+ end
470
+ end
471
+
472
+ # Internal method on the tracer pipeline.
473
+ # @api private
474
+ def insert_examples_coverage(db, coverage)
475
+ sql = 'INSERT INTO examples_coverage (example_id, file_path, line_key, strength) VALUES (?, ?, ?, ?)'
476
+ coverage.each do |id, per_file|
477
+ next unless per_file.is_a?(Hash)
478
+
479
+ per_file.each do |file_path, lines|
480
+ iterate_line_entries(lines) do |line_key, strength|
481
+ db.execute(sql, [id.to_s, file_path.to_s, line_key, strength])
482
+ end
483
+ end
484
+ end
485
+ end
486
+
487
+ # Accept both Hash[line_key => strength] (engine output) and
488
+ # Array[strength_or_nil] (raw ::Coverage.result shape) so specs
489
+ # that construct snapshots directly with Array values still
490
+ # round-trip. The canonical on-disk shape is (line_key_int,
491
+ # strength_int) rows; nil strengths drop out.
492
+ def iterate_line_entries(lines)
493
+ case lines
494
+ when Hash
495
+ lines.each { |line_key, strength| yield(line_key.to_i, strength.to_i) if strength }
496
+ when Array
497
+ lines.each_with_index { |strength, idx| yield(idx, strength.to_i) if strength }
498
+ end
499
+ end
500
+
501
+ # Internal method on the tracer pipeline.
502
+ # @api private
503
+ def insert_env_dependency(db, env_dep)
504
+ sql = 'INSERT INTO env_dependency (example_id, env_name) VALUES (?, ?)'
505
+ env_dep.each do |id, names|
506
+ Array(names).each { |name| db.execute(sql, [id.to_s, name.to_s]) }
507
+ end
508
+ end
509
+
510
+ # Internal method on the tracer pipeline.
511
+ # @api private
512
+ def insert_digest_map(db, kind, map)
513
+ sql = 'INSERT INTO digest_maps (kind, key, digest) VALUES (?, ?, ?)'
514
+ map.each { |k, digest| db.execute(sql, [kind, k.to_s, digest.to_s]) }
515
+ end
516
+
517
+ # Internal method on the tracer pipeline.
518
+ # @api private
519
+ def insert_id_set(db, status, ids)
520
+ sql = 'INSERT INTO id_sets (status, example_id) VALUES (?, ?)'
521
+ Array(ids.to_a).each { |id| db.execute(sql, [status, id.to_s]) }
522
+ end
523
+
524
+ # Internal method on the tracer pipeline.
525
+ # @api private
526
+ def dispatch_read(db, field)
527
+ case field
528
+ when :all_examples then read_all_examples(db)
529
+ when :duplicate_examples then read_duplicate_examples(db)
530
+ when :all_files then read_all_files(db)
531
+ when :dependency then read_dependency(db)
532
+ when :reverse_dependency then read_reverse_dependency(db)
533
+ when :examples_coverage then read_examples_coverage(db)
534
+ when :env_dependency then read_env_dependency(db)
535
+ when :cache_hit_reason then {} # JSON-backend-only surface; sqlite no-op
536
+ # ^^ SqliteBackend does not persist the cache_hit_reason aggregate; future
537
+ # enhancement may add a meta-table column. Empty default mirrors the
538
+ # missing-file-coerces-to-{} contract used by JsonBackend's per-field reads.
539
+ else dispatch_read_grouped(db, field)
540
+ end
541
+ end
542
+
543
+ # Internal method on the tracer pipeline.
544
+ # @api private
545
+ def dispatch_read_grouped(db, field)
546
+ return read_id_set(db, STATUS_FIELDS.fetch(field)) if STATUS_FIELDS.key?(field)
547
+
548
+ read_digest_map(db, DIGEST_MAP_KINDS.fetch(field))
549
+ end
550
+
551
+ # Internal method on the tracer pipeline.
552
+ # @api private
553
+ def read_all_examples(db)
554
+ result = {}
555
+ db.execute('SELECT example_id, metadata_json FROM examples').each do |row|
556
+ id, meta_json = row
557
+ result[id] = decode_hash_with_sym_keys(meta_json)
558
+ end
559
+ result
560
+ end
561
+
562
+ # Internal method on the tracer pipeline.
563
+ # @api private
564
+ def read_duplicate_examples(db)
565
+ result = Hash.new { |h, k| h[k] = [] }
566
+ rows = db.execute('SELECT example_id, idx, entry_json FROM duplicate_examples ORDER BY example_id, idx')
567
+ rows.each do |row|
568
+ id, _idx, entry_json = row
569
+ result[id] << decode_hash_with_sym_keys(entry_json)
570
+ end
571
+ result.each_with_object({}) { |(k, v), h| h[k] = v } # strip default_proc
572
+ end
573
+
574
+ # Internal method on the tracer pipeline.
575
+ # @api private
576
+ def read_all_files(db)
577
+ result = {}
578
+ db.execute('SELECT file_name, file_path, digest FROM all_files').each do |row|
579
+ name, path, digest = row
580
+ result[name] = { file_name: name, file_path: path, digest: digest }
581
+ end
582
+ result
583
+ end
584
+
585
+ # Internal method on the tracer pipeline.
586
+ # @api private
587
+ def read_dependency(db)
588
+ result = Hash.new { |h, k| h[k] = Set.new }
589
+ db.execute('SELECT example_id, file_name FROM dependency').each do |row|
590
+ id, name = row
591
+ result[id] << name
592
+ end
593
+ result.each_with_object({}) { |(k, v), h| h[k] = v }
594
+ end
595
+
596
+ # Internal method on the tracer pipeline.
597
+ # @api private
598
+ def read_reverse_dependency(db)
599
+ result = Hash.new { |h, k| h[k] = Set.new }
600
+ db.execute('SELECT file_name, example_id FROM dependency').each do |row|
601
+ name, id = row
602
+ result[name] << id
603
+ end
604
+ result.each_with_object({}) { |(k, v), h| h[k] = v }
605
+ end
606
+
607
+ # Internal method on the tracer pipeline.
608
+ # @api private
609
+ def read_examples_coverage(db)
610
+ result = {}
611
+ rows = db.execute('SELECT example_id, file_path, line_key, strength FROM examples_coverage')
612
+ rows.each do |row|
613
+ id, path, line_key, strength = row
614
+ result[id] ||= {}
615
+ (result[id][path] ||= {})[line_key.to_i] = strength.to_i
616
+ end
617
+ result
618
+ end
619
+
620
+ # Internal method on the tracer pipeline.
621
+ # @api private
622
+ def read_env_dependency(db)
623
+ result = Hash.new { |h, k| h[k] = [] }
624
+ rows = db.execute('SELECT example_id, env_name FROM env_dependency ORDER BY example_id, env_name')
625
+ rows.each do |row|
626
+ id, name = row
627
+ result[id] << name
628
+ end
629
+ result.each_with_object({}) { |(k, v), h| h[k] = v }
630
+ end
631
+
632
+ # Internal method on the tracer pipeline.
633
+ # @api private
634
+ def read_id_set(db, status)
635
+ result = Set.new
636
+ db.execute('SELECT example_id FROM id_sets WHERE status = ?', [status]).each do |row|
637
+ result << row.first
638
+ end
639
+ result
640
+ end
641
+
642
+ # Internal method on the tracer pipeline.
643
+ # @api private
644
+ def read_digest_map(db, kind)
645
+ result = {}
646
+ db.execute('SELECT key, digest FROM digest_maps WHERE kind = ?', [kind]).each do |row|
647
+ key, digest = row
648
+ result[key] = digest
649
+ end
650
+ result
651
+ end
652
+
653
+ # Internal method on the tracer pipeline.
654
+ # @api private
655
+ def empty_default_for(field)
656
+ return Set.new if STATUS_FIELDS.key?(field)
657
+
658
+ {}
659
+ end
660
+
661
+ # Internal method on the tracer pipeline.
662
+ # @api private
663
+ def decode_hash_with_sym_keys(json_bytes)
664
+ parsed = ::JSON.parse(json_bytes)
665
+ return parsed unless parsed.is_a?(Hash)
666
+
667
+ parsed.transform_keys(&:to_sym)
668
+ end
669
+
670
+ # Internal method on the tracer pipeline.
671
+ # @api private
672
+ def fetch_hash(hash, key)
673
+ return hash[key] if hash.key?(key)
674
+
675
+ hash[key.to_s]
676
+ end
677
+
678
+ # Internal method on the tracer pipeline.
679
+ # @api private
680
+ def info(message)
681
+ @logger&.info(message)
682
+ end
683
+ end
684
+ # rubocop:enable Metrics/ClassLength
685
+ end
686
+ end