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