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,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'testsift/storage/backend'
4
+ require 'testsift/storage/json_backend'
5
+ require 'testsift/storage/schema'
6
+ require 'testsift/storage/sqlite_backend' if RUBY_ENGINE == 'ruby'
7
+
8
+ module TestSift
9
+ # Internal CLI -- see {TestSift} for the user-facing surface.
10
+ # @api private
11
+ module CLI
12
+ # Shared plumbing for the snapshot-reading sub-commands (Explain,
13
+ # BlastRadius): help-flag detection, backend-agnostic snapshot
14
+ # loading with uniform degradation messages, and String/Symbol-
15
+ # tolerant metadata lookups. Extracted so the two commands cannot
16
+ # drift apart on message wording or cache-shape tolerance.
17
+ # @api private
18
+ module SnapshotHelpers
19
+ # @param args [Array<String>] sub-command args (flags removed
20
+ # by the caller where applicable)
21
+ # @return [Boolean] true when the sub-command should print its
22
+ # help text instead of executing
23
+ def self.help_requested?(args)
24
+ args.empty? || args.include?('-h') || args.include?('--help')
25
+ end
26
+
27
+ # Resolve the persisted snapshot through the configured storage
28
+ # backend ({TestSift::Storage::Backend.build}), so
29
+ # `storage_backend :sqlite` resolves the latest run from the
30
+ # meta table instead of the JsonBackend-only `last_run.json`.
31
+ #
32
+ # @param cache_path [String] root cache directory
33
+ # @param command [String] sub-command name used as the message
34
+ # prefix (e.g. `explain`, `blast-radius`)
35
+ # @param stderr [IO]
36
+ # @return [Array(Object, Object), nil] `[snapshot, backend]` on
37
+ # success; nil (after printing a one-line explanation) when no
38
+ # run has been recorded yet or the cache schema is
39
+ # incompatible
40
+ def self.load_snapshot(cache_path, command:, stderr:)
41
+ backend = Storage::Backend.build(cache_path: cache_path, configuration: TestSift)
42
+ run_id = backend.last_run_id
43
+ if run_id.nil? || run_id.to_s.empty?
44
+ stderr.puts "#{command}: no cache yet at #{cache_path} -- run rspec first"
45
+ return nil
46
+ end
47
+
48
+ snapshot = backend.load_graph(schema_version: Storage::Schema::CURRENT)
49
+ if snapshot.nil?
50
+ stderr.puts "#{command}: cache at #{cache_path} is incompatible with this testsift; " \
51
+ 'next rspec run is cold'
52
+ return nil
53
+ end
54
+
55
+ [snapshot, backend]
56
+ end
57
+
58
+ # Whether the backend persists the per-run filter decisions
59
+ # (`filtered_examples` / `skipped_examples`). JsonBackend does;
60
+ # SqliteBackend deliberately does not (its `dispatch_read`
61
+ # returns `{}` for those fields). Callers use this to attribute
62
+ # an empty decision set honestly: on a persisting backend it
63
+ # means "no decision was recorded for this id" (cold run, or the
64
+ # id was not part of the last run), NOT a backend limitation.
65
+ #
66
+ # @param backend [Object] a {TestSift::Storage::Backend.build}
67
+ # product
68
+ # @return [Boolean]
69
+ def self.filter_decisions_persisted?(backend)
70
+ backend.is_a?(Storage::JsonBackend)
71
+ end
72
+
73
+ # Look up a key from a Hash, tolerating both String and Symbol
74
+ # storage. Snapshot Hashes round-tripped through JSON yield
75
+ # String keys; the post-#182 msgpack serializer preserves
76
+ # Symbol keys end-to-end, so callers can't assume either shape.
77
+ def self.fetch_meta(meta, *keys)
78
+ keys.each do |k|
79
+ v = meta[k]
80
+ return v unless v.nil?
81
+
82
+ sym_value = meta[k.to_sym]
83
+ return sym_value unless sym_value.nil?
84
+ end
85
+ nil
86
+ end
87
+
88
+ # Look up a nested key from a Hash, tolerating both String and
89
+ # Symbol storage at each level. See {.fetch_meta} for rationale.
90
+ def self.dig_meta(meta, *keys)
91
+ keys.reduce(meta) do |acc, k|
92
+ break nil if acc.nil? || !acc.is_a?(::Hash)
93
+
94
+ acc[k] || acc[k.to_sym]
95
+ end
96
+ end
97
+
98
+ # The example's rerun location from persisted all_examples meta,
99
+ # preferring the rerun_* fields the reporter records.
100
+ #
101
+ # @param meta [Hash] one all_examples entry
102
+ # @return [Array(Object, Object)] `[file, line]`; either element
103
+ # may be nil when the meta is missing the field
104
+ def self.example_location(meta)
105
+ [
106
+ fetch_meta(meta, 'rerun_file_name', 'file_name'),
107
+ fetch_meta(meta, 'rerun_line_number', 'line_number')
108
+ ]
109
+ end
110
+
111
+ # @param meta [Hash] one all_examples entry
112
+ # @return [String, nil] the example's description, or nil when
113
+ # the meta is missing both description fields
114
+ def self.example_description(meta)
115
+ fetch_meta(meta, 'full_description', 'description')
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+
5
+ # Only the Logger class -- NOT the full library, whose boot would
6
+ # `load` the project config. {CLI.with_default_logger_out} needs the
7
+ # class defined before that boot so the config-load window cannot
8
+ # construct a stdout-bound logger.
9
+ require_relative 'logger'
10
+
11
+ module TestSift
12
+ # Command-line entry for the `testsift` binary. Six sub-commands
13
+ # per USER_FACING_SURFACE.md section 10: `doctor`, `cache:info`,
14
+ # `cache:clear`, `report:open`, `explain <example>`,
15
+ # `blast-radius <file>`.
16
+ #
17
+ # The CLI is opt-in — the canonical CI flow continues to go through
18
+ # `rake testsift:remote_cache:*` tasks per USER_FACING_SURFACE.md
19
+ # §5. Sub-commands operate against the local cache + report
20
+ # directories resolved from the project's `.testsift` config.
21
+ # {.run} loads the tracer library (and with it the project + global
22
+ # `.testsift` configs) via {.load_tracer} right before
23
+ # sub-command dispatch, so `--help` / `--version` work even in a
24
+ # project whose config is broken, and a config file that raises at
25
+ # load time degrades to a one-line message + exit 1 instead of a
26
+ # raw backtrace.
27
+ #
28
+ # @api private
29
+ module CLI
30
+ # Internal constant.
31
+ # @api private
32
+ SUB_COMMANDS = {
33
+ 'doctor' => 'Doctor',
34
+ 'cache:info' => 'CacheInfo',
35
+ 'cache:clear' => 'CacheClear',
36
+ 'report:open' => 'ReportOpen',
37
+ 'explain' => 'Explain',
38
+ 'blast-radius' => 'BlastRadius'
39
+ }.freeze
40
+
41
+ # CLI entry. Called by `bin/testsift` with `ARGV`. Wraps every
42
+ # sub-command in a top-level rescue so the binary always exits
43
+ # with a meaningful integer status (0 / 1) instead of a backtrace.
44
+ #
45
+ # @param argv [Array<String>] command-line arguments (excluding
46
+ # the program name)
47
+ # @param stdout [IO] stream for normal output (default `$stdout`)
48
+ # @param stderr [IO] stream for errors / diagnostics (default `$stderr`)
49
+ # @return [Integer] exit status (0 = success, 1 = failure)
50
+ def self.run(argv, stdout: $stdout, stderr: $stderr)
51
+ args = argv.dup
52
+ return print_top_level_help(stdout) if args.empty? || %w[-h --help help].include?(args.first)
53
+ return print_version(stdout) if %w[-v --version].include?(args.first)
54
+
55
+ with_default_logger_out(stderr) do
56
+ if load_tracer(stderr)
57
+ with_logger_on(stderr) { dispatch(args, stdout: stdout, stderr: stderr) }
58
+ else
59
+ 1
60
+ end
61
+ end
62
+ rescue Errno::EPIPE
63
+ # A downstream pipe consumer (`testsift ... | head`) closed
64
+ # early -- routine in shell pipelines, not a failure. Exit 0
65
+ # without printing: writing to stderr could raise EPIPE again
66
+ # when both streams share the closed pipe.
67
+ 0
68
+ rescue StandardError => e
69
+ stderr.puts "testsift: #{e.class}: #{e.message}"
70
+ 1
71
+ end
72
+
73
+ # Boot the tracer library for sub-command dispatch. Loading
74
+ # `testsift` pulls in `docile` (used by
75
+ # `Configuration#configure`), the modules sub-commands rely on,
76
+ # AND the project's `.testsift` / `~/.testsift` configs
77
+ # (arbitrary user Ruby, `load`ed by `load_config`). It does NOT
78
+ # call `TestSift.start` - the engine stays inert until a user
79
+ # invokes it explicitly.
80
+ #
81
+ # A raising config must not crash the binary with a backtrace:
82
+ # rescue ScriptError as well as StandardError because a syntax
83
+ # error in `.testsift` raises SyntaxError, which is not a
84
+ # StandardError and would sail past {.run}'s rescue.
85
+ #
86
+ # @param stderr [IO]
87
+ # @return [Boolean] true when the tracer library (and the user
88
+ # configs it loads) booted cleanly
89
+ def self.load_tracer(stderr)
90
+ require 'testsift'
91
+ true
92
+ rescue ScriptError, StandardError => e
93
+ stderr.puts "testsift: could not load configuration (.testsift): #{e.class}: #{e.message}"
94
+ false
95
+ end
96
+
97
+ # Point {TestSift::Logger.default_out} at the CLI's stderr for
98
+ # the duration of library boot + sub-command dispatch, restoring
99
+ # the previous value afterwards. This must wrap {.load_tracer}:
100
+ # booting the library `load`s the project + global `.testsift`
101
+ # configs, and those can write through the logger BEFORE
102
+ # {.with_logger_on} ever runs -- the 1.x-compat deprecation shims
103
+ # (`reports_s3_path`, `use_local_aws`) fire a one-time
104
+ # `logger.warn` at first use, and in a fresh CLI process
105
+ # "one-time" means every invocation. The logger's default
106
+ # destination is stdout -- the stream machine consumers parse --
107
+ # so that warning would land ahead of e.g. the `blast-radius
108
+ # --json` document and break `| jq`. With the default rebound,
109
+ # every logger constructed during the window binds to stderr,
110
+ # including loggers recreated mid-load when a config sets
111
+ # `log_level` (which resets the memoized instance) after a
112
+ # deprecated DSL call. Normal `rspec` runs (whose users expect
113
+ # stdout logs) never enter this path and stay untouched.
114
+ # @api private
115
+ def self.with_default_logger_out(stderr)
116
+ previous = TestSift::Logger.default_out
117
+ TestSift::Logger.default_out = stderr
118
+ yield
119
+ ensure
120
+ TestSift::Logger.default_out = previous
121
+ end
122
+
123
+ # Bind the tracer's internal logger to the CLI's stderr for the
124
+ # duration of sub-command dispatch, restoring the previous logger
125
+ # afterwards. Backend diagnostics run through `TestSift.logger`
126
+ # (e.g. {TestSift::Storage::Backend.build}'s sqlite-unavailable
127
+ # fallback warning, JsonBackend's schema-mismatch info line).
128
+ # {.with_default_logger_out} already redirects loggers constructed
129
+ # during the CLI window; this layer additionally rebinds a logger
130
+ # that was memoized into `TestSift.@logger` BEFORE {.run} was
131
+ # called (possible for in-process callers -- the spec suite, or a
132
+ # user embedding the CLI -- where the library booted earlier with
133
+ # the stdout default). Sub-commands like `blast-radius --json`
134
+ # promise exactly one JSON document on stdout, so a diagnostic
135
+ # line printed ahead of it breaks `| jq`; together the two layers
136
+ # keep ALL diagnostics on stderr alongside the CLI's own messages.
137
+ #
138
+ # `TestSift.logger` memoizes into `@logger`
139
+ # (Configuration#logger); the ivar is seeded directly because
140
+ # Configuration deliberately exposes no logger setter -- adding
141
+ # one would leak it into the user-facing `configure` DSL surface.
142
+ # @api private
143
+ def self.with_logger_on(stderr)
144
+ previous = TestSift.instance_variable_get(:@logger)
145
+ TestSift.instance_variable_set(
146
+ :@logger, TestSift::Logger.new(TestSift.log_level, out: stderr)
147
+ )
148
+ yield
149
+ ensure
150
+ TestSift.instance_variable_set(:@logger, previous)
151
+ end
152
+
153
+ # Internal helper for the tracer pipeline.
154
+ # @api private
155
+ def self.dispatch(args, stdout:, stderr:)
156
+ sub = args.shift
157
+ klass_name = SUB_COMMANDS[sub]
158
+ return unknown_sub_command(sub, stderr) if klass_name.nil?
159
+
160
+ load_sub_command(klass_name)
161
+ TestSift::CLI.const_get(klass_name).run(args, stdout: stdout, stderr: stderr)
162
+ end
163
+
164
+ # Internal helper for the tracer pipeline.
165
+ # @api private
166
+ def self.unknown_sub_command(sub, stderr)
167
+ stderr.puts "testsift: unknown sub-command #{sub.inspect}"
168
+ stderr.puts " available: #{SUB_COMMANDS.keys.join(', ')}"
169
+ 1
170
+ end
171
+
172
+ # Internal helper for the tracer pipeline.
173
+ # @api private
174
+ def self.print_top_level_help(stdout)
175
+ stdout.puts <<~HELP
176
+ Usage: testsift <sub-command> [options]
177
+
178
+ Sub-commands:
179
+ doctor Diagnose testsift config + environment.
180
+ cache:info Show cache size, last run timestamp, and example counts.
181
+ cache:clear Remove cache, coverage, and report directories.
182
+ report:open Open the HTML report in the default browser.
183
+ explain <id> Show why an example is scheduled to run or skip.
184
+ blast-radius <f> Show which examples a file change would re-run.
185
+
186
+ Options:
187
+ -h, --help Print this help message.
188
+ -v, --version Print testsift version.
189
+
190
+ Run `testsift <sub-command> --help` for sub-command options.
191
+ HELP
192
+ 0
193
+ end
194
+
195
+ # Internal helper for the tracer pipeline.
196
+ # @api private
197
+ def self.print_version(stdout)
198
+ # Only the version constant - not the full library, whose boot
199
+ # would `load` the project config and could raise before the
200
+ # version ever printed.
201
+ require 'testsift/version'
202
+ stdout.puts "testsift #{TestSift::VERSION}"
203
+ 0
204
+ end
205
+
206
+ # Internal helper for the tracer pipeline.
207
+ # @api private
208
+ def self.load_sub_command(klass_name)
209
+ filename = klass_name.gsub(/([A-Z])/) { |m| "_#{m.downcase}" }.sub(/^_/, '')
210
+ require_relative "cli/#{filename}"
211
+ rescue LoadError => e
212
+ # LoadError isn't a StandardError, so the outer `rescue StandardError`
213
+ # in `.run` wouldn't catch it. Re-raise as StandardError so the dispatch
214
+ # path stays uniform: any sub-command resolution failure prints
215
+ # `testsift: <class>: <message>` and exits 1.
216
+ raise StandardError, "could not load sub-command #{klass_name.inspect}: #{e.message}"
217
+ end
218
+ end
219
+ end