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,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TestSift
4
+ # Builds the identity-hash payload (`:example_id`-keyed Hash) that
5
+ # RSpec::RunnerHook attaches to every example pre-run.
6
+ #
7
+ # == Identity stability contract
8
+ #
9
+ # `example_id` is the MD5 of a stable subset of the payload:
10
+ # `example_group` (the describe block's *description* string),
11
+ # `description`, `full_description`, `shared_group` (inclusion
12
+ # locations with the trailing line number stripped), and
13
+ # `file_name`. The contract, in one line: *rename = new identity;
14
+ # restructure = same identity.*
15
+ #
16
+ # Identity is PRESERVED when:
17
+ # - blank lines or comments are added/removed around the example
18
+ # - examples are reordered within a describe block (named
19
+ # examples only - see "Unnamed examples" below)
20
+ # - a sibling describe / example in the same file is renamed
21
+ # - metadata changes (`skip:`, tags, `tracks: { ... }`) - the
22
+ # spec-file digest still triggers the re-run and status history
23
+ # is kept
24
+ # - the example body or its hooks (`before`, `let`) are edited -
25
+ # again, the file digest triggers the re-run
26
+ #
27
+ # Identity CHANGES (one cold "No cache" run) when:
28
+ # - the file is renamed or moved
29
+ # - the `describe` / `it` / shared-example name is changed
30
+ # - the example moves to a different describe block
31
+ #
32
+ # == Unnamed examples (`it { }`, `specify { }`, `example { }`)
33
+ #
34
+ # An example with no description string has no stable name to hash,
35
+ # and identity must be computed pre-run (for the filter decision),
36
+ # before RSpec generates a matcher-derived description. The only
37
+ # line-independent signal RSpec exposes pre-run is position, so an
38
+ # unnamed example's identity is derived from its ordinal among the
39
+ # *unnamed* examples of its group. (RSpec's `description` for an
40
+ # unnamed example is the line-bearing `"example at <path>:<line>"`
41
+ # fallback, which would otherwise leak the line number straight
42
+ # back into the digest - issue #210.)
43
+ #
44
+ # For unnamed examples the contract above is amended: identity is
45
+ # still PRESERVED across blank-line / comment edits, sibling
46
+ # renames, and adding or removing *named* siblings - but it CHANGES
47
+ # when the unnamed examples are reordered, or one is inserted or
48
+ # removed ahead of it. Give an example an explicit description
49
+ # (`it 'does X' do`) for a fully reorder-stable identity.
50
+ #
51
+ # `line_number` / `rerun_file_name` / `rerun_line_number` stay in
52
+ # the returned Hash for the reporter + `explain` location columns,
53
+ # but are DELIBERATELY EXCLUDED from the digest - a no-op edit that
54
+ # shifts line numbers must not invalidate the cache. `example_group`
55
+ # uses `example_group.description` (the user's string) rather than
56
+ # `example_group.name`: RSpec's generated class name carries a
57
+ # load-order-dependent `_2` / `_3` suffix when two files share a
58
+ # describe name, which would otherwise flip the id across runs.
59
+ #
60
+ # Helpers are `def self.x` + `private_class_method` so mutant
61
+ # attributes mutations through the singleton call path.
62
+ module Example
63
+ # Identity-keyed cache of `<example_group> => Array<unnamed sibling>`
64
+ # populated lazily by `unnamed_description`. A group with N unnamed
65
+ # examples computes the sibling list once per group rather than N
66
+ # times. Memory is bounded by the live group set (RSpec retains
67
+ # those for the run anyway).
68
+ @unnamed_siblings_cache = {}.compare_by_identity
69
+
70
+ # Builds the identity payload for one RSpec example. The MD5 is
71
+ # taken over the stability-contract subset (see `digest_identity`
72
+ # for the named-vs-unnamed split); `line_number` / `rerun_*` ride
73
+ # along in the returned Hash for the reporters but never enter the
74
+ # digest. See the module comment for the full stability contract.
75
+ # @api private
76
+ def self.from(example)
77
+ location = example_location(example)
78
+ identity = {
79
+ example_group: example.example_group.description,
80
+ description: example.description,
81
+ full_description: example.full_description,
82
+ shared_group: example.metadata[:shared_group_inclusion_backtrace]
83
+ .map { |frame| frame.formatted_inclusion_location.sub(/:\d+\z/, '') },
84
+ file_name: location[:file_name]
85
+ }
86
+
87
+ identity
88
+ .merge(location)
89
+ .merge(example_id: Digest::MD5.hexdigest(digest_identity(example, identity).to_json))
90
+ end
91
+
92
+ # The Hash actually fed to the MD5. For a named example this is
93
+ # `identity` unchanged - byte-identical to the pre-#210 digest.
94
+ # For an unnamed example (`it { }` / `specify { }` / `example { }`)
95
+ # RSpec's `description` is the line-bearing location fallback
96
+ # `"example at <path>:<line>"`, so `description` is swapped for a
97
+ # line-independent positional discriminator before hashing. The
98
+ # returned/stored payload still carries RSpec's `description` /
99
+ # `full_description` untouched - only the digest input differs.
100
+ # @api private
101
+ def self.digest_identity(example, identity)
102
+ return identity unless unnamed?(example)
103
+
104
+ identity.merge(description: unnamed_description(example))
105
+ end
106
+
107
+ # True when the example has no explicit description string. RSpec's
108
+ # raw `metadata[:description]` is `""` for `it { }` / `specify { }`
109
+ # / `example { }`; the `description` *method* would instead return
110
+ # the line-bearing `"example at <path>:<line>"` fallback, so the
111
+ # raw metadata value is what cleanly tells named from unnamed.
112
+ # @api private
113
+ def self.unnamed?(example)
114
+ example.metadata[:description].to_s.strip.empty?
115
+ end
116
+
117
+ # Line-independent identity discriminator for an unnamed example:
118
+ # its 0-based ordinal among the *unnamed* examples of its group.
119
+ # Stable across blank-line / comment edits and across adding or
120
+ # renaming *named* siblings; changes only when the unnamed examples
121
+ # are reordered or one is inserted / removed ahead of it (the
122
+ # documented carve-out - see the module comment). Closes the
123
+ # remaining #196 gap reported in #210.
124
+ #
125
+ # The unnamed-siblings list is memoized per example_group object,
126
+ # so a group with N unnamed examples computes the list once, not N
127
+ # times. The discriminator string takes a Ruby-inspect-style
128
+ # `#<...>` form to make spoofing implausible - and even if a user
129
+ # description literally matched, full_description would still
130
+ # differ between the unnamed example (`"<group> "`) and the named
131
+ # one (`"<group> #<...>"`), so no real digest collision is
132
+ # possible.
133
+ # @api private
134
+ def self.unnamed_description(example)
135
+ group = example.example_group
136
+ unnamed_siblings = @unnamed_siblings_cache[group] ||=
137
+ group.examples.select { |sibling| unnamed?(sibling) }
138
+
139
+ "#<testsift unnamed example #{unnamed_siblings.index(example)}>"
140
+ end
141
+
142
+ # Internal helper for the tracer pipeline.
143
+ # @api private
144
+ def self.example_location(example)
145
+ metadata = example.metadata
146
+
147
+ location = {
148
+ file_name: location_file_name(metadata[:file_path]),
149
+ line_number: metadata[:line_number]
150
+ }
151
+
152
+ if metadata[:file_path] == metadata[:rerun_file_path]
153
+ return location.merge(
154
+ rerun_file_name: location[:file_name],
155
+ rerun_line_number: location[:line_number]
156
+ )
157
+ end
158
+
159
+ location.merge(example_rerun_location(example.example_group.parent_groups))
160
+ end
161
+
162
+ # Internal helper for the tracer pipeline.
163
+ # @api private
164
+ def self.example_rerun_location(example_groups)
165
+ example_groups.each do |example_group|
166
+ metadata = example_group.metadata
167
+
168
+ next unless metadata[:file_path] == metadata[:rerun_file_path]
169
+
170
+ return {
171
+ rerun_file_name: location_file_name(metadata[:file_path]),
172
+ rerun_line_number: metadata[:line_number]
173
+ }
174
+ end
175
+ end
176
+
177
+ # Internal helper for the tracer pipeline.
178
+ # @api private
179
+ def self.location_file_name(rspec_file_name)
180
+ file_path = TestSift::SourceFile.file_path(rspec_file_name)
181
+
182
+ TestSift::SourceFile.file_name(file_path)
183
+ end
184
+
185
+ private_class_method :digest_identity, :unnamed?, :unnamed_description,
186
+ :example_location, :example_rerun_location, :location_file_name
187
+ end
188
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TestSift
4
+ # Internal Filter — see {TestSift} for the user-facing surface.
5
+ # @api private
6
+ #
7
+ # Internal dispatch shim for the user-facing `add_filter` /
8
+ # `add_coverage_filter` DSL. The user passes a String / Regexp /
9
+ # Proc / Array; `Filter.register` wraps it in the matching subclass
10
+ # so the engine can call a uniform `#match?(source_file)`.
11
+ class Filter
12
+ # Internal attribute.
13
+ # @api private
14
+ attr_reader :filter
15
+
16
+ # Internal helper for the tracer pipeline.
17
+ # @api private
18
+ def self.register(filter)
19
+ return filter if filter.is_a?(Filter)
20
+
21
+ filter_class(filter).new(filter)
22
+ end
23
+
24
+ # Internal helper for the tracer pipeline.
25
+ # @api private
26
+ def self.filter_class(filter)
27
+ case filter
28
+ when String
29
+ StringFilter
30
+ when Regexp
31
+ RegexFilter
32
+ when Proc
33
+ BlockFilter
34
+ when Array
35
+ ArrayFilter
36
+ else
37
+ raise ArgumentError, 'Unknow filter'
38
+ end
39
+ end
40
+
41
+ # Internal method on the tracer pipeline.
42
+ # @api private
43
+ def initialize(filter)
44
+ @filter = filter
45
+ end
46
+
47
+ # Internal method on the tracer pipeline.
48
+ # @api private
49
+ def match?(_source_file)
50
+ raise "#{self.class.name}#match? is not intended for direct use"
51
+ end
52
+ end
53
+
54
+ # Internal ArrayFilter — see {TestSift} for the user-facing surface.
55
+ # @api private
56
+ class ArrayFilter < TestSift::Filter
57
+ # Internal method on the tracer pipeline.
58
+ # @api private
59
+ def initialize(filters)
60
+ filter_list = filters.each_with_object([]) do |filter, list|
61
+ list << Filter.register(filter)
62
+ end
63
+
64
+ super(filter_list)
65
+ end
66
+
67
+ # Internal method on the tracer pipeline.
68
+ # @api private
69
+ def match?(source_file)
70
+ @filter.any? { |filter| filter.match?(source_file) }
71
+ end
72
+ end
73
+
74
+ # Internal BlockFilter — see {TestSift} for the user-facing surface.
75
+ # @api private
76
+ class BlockFilter < TestSift::Filter
77
+ # Internal method on the tracer pipeline.
78
+ # @api private
79
+ def match?(source_file)
80
+ @filter.call(source_file)
81
+ end
82
+ end
83
+
84
+ # Internal RegexFilter — see {TestSift} for the user-facing surface.
85
+ # @api private
86
+ class RegexFilter < TestSift::Filter
87
+ # Internal method on the tracer pipeline.
88
+ # @api private
89
+ def match?(source_file)
90
+ source_file[:file_name] =~ @filter
91
+ end
92
+ end
93
+
94
+ # Internal StringFilter — see {TestSift} for the user-facing surface.
95
+ # @api private
96
+ class StringFilter < TestSift::Filter
97
+ # Internal method on the tracer pipeline.
98
+ # @api private
99
+ def match?(source_file)
100
+ source_file[:file_name].include?(@filter)
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TestSift
4
+ # Per-engine line-stub builder for files that need a synthetic
5
+ # all-nil-but-executable-lines coverage array. Used by
6
+ # `Reporters::CoverageJsonReporter` when a tracked file is in
7
+ # `coverage_tracked_files` but has no recorded coverage (file never
8
+ # loaded during this run).
9
+ #
10
+ # Lives at the top level of lib/testsift so the per-engine
11
+ # branches sit outside the tracker_coverage_gate's
12
+ # 100%-line+branch contract (the JRuby branch cannot be exercised
13
+ # on MRI; cross-engine coverage rollup would require fork-per-engine
14
+ # CI work that isn't justified for stub-line generation).
15
+ #
16
+ # Methods use `def self.x` (not module_function) so future mutation
17
+ # gating maps to the singleton form -- module_function attaches
18
+ # methods to an anonymous singleton that mutant cannot observe.
19
+ module LineStub
20
+ # Internal helper for the tracer pipeline.
21
+ # @api private
22
+ def self.for(file_path)
23
+ case RUBY_ENGINE
24
+ when 'ruby'
25
+ ruby(file_path)
26
+ when 'jruby'
27
+ jruby(file_path)
28
+ else
29
+ File.foreach(file_path).map { nil }
30
+ end
31
+ end
32
+
33
+ # Internal helper for the tracer pipeline.
34
+ # @api private
35
+ def self.ruby(file_path)
36
+ lines = File.foreach(file_path).map { nil }
37
+ iseqs = [::RubyVM::InstructionSequence.compile_file(file_path)]
38
+ until iseqs.empty?
39
+ iseq = iseqs.pop
40
+ iseq.trace_points.each { |line_number, type| lines[line_number - 1] = 0 if type == :line }
41
+ iseq.each_child { |child| iseqs << child }
42
+ end
43
+ lines
44
+ end
45
+
46
+ # Internal helper for the tracer pipeline.
47
+ # @api private
48
+ def self.jruby(file_path)
49
+ lines = File.foreach(file_path).map { nil }
50
+ root_node = ::JRuby.parse(File.read(file_path, encoding: 'UTF-8'))
51
+ visitor = org.jruby.ast.visitor.NodeVisitor.impl do |_name, node|
52
+ if node.newline?
53
+ ln = node.respond_to?(:position) ? node.position.line : node.line
54
+ lines[ln] = 0
55
+ end
56
+ node.child_nodes.each { |child| child&.accept(visitor) }
57
+ end
58
+ root_node.accept(visitor)
59
+ lines
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'configuration'
4
+ TestSift.extend TestSift::Configuration
5
+
6
+ require_relative 'load_default_config'
7
+ require_relative 'load_global_config'
8
+ require_relative 'load_local_config'
9
+
10
+ # NOTE: `Configuration#configure` installs the public DSL wrappers
11
+ # (alias `_name` + redefine `name` to forward `|*args, **kwargs, &block|`)
12
+ # the first time any configurer runs. `load_default_config` is
13
+ # unconditional, so the wrappers are guaranteed to exist before anyone
14
+ # can call `TestSift.add_filter` etc. No second install step is
15
+ # needed here.
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/BlockLength
4
+ TestSift.configure do
5
+ log_level :info
6
+ fail_on_duplicates true
7
+
8
+ filters.clear
9
+ add_filter %w[
10
+ /vendor/bundle/
11
+ /lib/testsift/
12
+ /lib/testsift.rb
13
+ /usr/local/lib/ruby/
14
+ /usr/local/bundle/
15
+ /opt/hostedtoolcache/
16
+ /.rbenv/versions/
17
+ /.asdf/installs/ruby/
18
+ /.rvm/
19
+ /testsift_cache/
20
+ /testsift_coverage/
21
+ /testsift_report/
22
+ testsift.lock
23
+ ].freeze
24
+
25
+ coverage_filters.clear
26
+ add_coverage_filter %w[
27
+ /autotest/
28
+ /features/
29
+ /spec/
30
+ /test/
31
+ /vendor/bundle/
32
+ /lib/testsift/
33
+ /lib/testsift.rb
34
+ /usr/local/lib/ruby/
35
+ /usr/local/bundle/
36
+ /opt/hostedtoolcache/
37
+ /.rbenv/versions/
38
+ /.asdf/installs/ruby/
39
+ /.rvm/
40
+ /testsift_cache/
41
+ /testsift_coverage/
42
+ /testsift_report/
43
+ testsift.lock
44
+ ].freeze
45
+ end
46
+ # rubocop:enable Metrics/BlockLength
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'etc'
4
+
5
+ # Resolve the user's home directory without raising at gem-load time. Each
6
+ # primitive (Dir.home, Etc.getpwuid.dir, File.expand_path("~user")) can raise
7
+ # ArgumentError when HOME is unset and the passwd database has no matching
8
+ # entry (common in minimal containers and CI images). Graceful degradation:
9
+ # if none resolves, skip global config entirely rather than aborting boot.
10
+ home_dir = begin
11
+ Dir.home
12
+ rescue ArgumentError
13
+ nil
14
+ end
15
+
16
+ home_dir ||= begin
17
+ Etc.getpwuid.dir
18
+ rescue ArgumentError
19
+ nil
20
+ end
21
+
22
+ user = ENV.fetch('USER', nil)
23
+ home_dir ||= begin
24
+ File.expand_path("~#{user}") if user
25
+ rescue ArgumentError
26
+ nil
27
+ end
28
+
29
+ if home_dir
30
+ global_config_path = File.join(home_dir, '.testsift')
31
+
32
+ load global_config_path if File.exist?(global_config_path)
33
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ config_path = Pathname.new(TestSift.root)
4
+
5
+ loop do
6
+ filename = config_path.join('.testsift')
7
+
8
+ if filename.exist?
9
+ load filename
10
+
11
+ break
12
+ end
13
+
14
+ config_path, = config_path.split
15
+
16
+ break if config_path.root?
17
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TestSift
4
+ # Internal Logger — see {TestSift} for the user-facing surface.
5
+ # @api private
6
+ #
7
+ # Internal logger; thin wrapper around `IO#puts` gated on numeric
8
+ # log level. Exposed via `TestSift.logger`.
9
+ class Logger
10
+ class << self
11
+ # Process-wide default destination for loggers constructed
12
+ # without an explicit `out:`. `nil` (the norm) means `$stdout`,
13
+ # which normal `rspec` runs rely on. The `testsift` binary
14
+ # sets this to its stderr BEFORE booting the tracer library, so
15
+ # loggers constructed while the project's `.testsift` config
16
+ # loads (e.g. by the 1.x deprecation shims, or recreated after a
17
+ # config `log_level` call resets the memoized instance) already
18
+ # bind to stderr and machine-readable stdout (`blast-radius
19
+ # --json`) stays free of diagnostics. See {TestSift::CLI}.
20
+ #
21
+ # @return [IO, nil]
22
+ # @api private
23
+ attr_accessor :default_out
24
+ end
25
+
26
+ # @param log_level [Integer] numeric level (Configuration::LOG_LEVEL)
27
+ # @param out [IO, nil] destination stream. Falls back to
28
+ # {Logger.default_out} when nil, then to `$stdout`.
29
+ # @api private
30
+ def initialize(log_level, out: nil)
31
+ @log_level = log_level
32
+ @out = out || self.class.default_out || $stdout
33
+ end
34
+
35
+ # Internal method on the tracer pipeline.
36
+ # @api private
37
+ def debug(message)
38
+ @out.puts message if @log_level == 1
39
+ end
40
+
41
+ # Internal method on the tracer pipeline.
42
+ # @api private
43
+ def info(message)
44
+ @out.puts message if @log_level.between?(1, 2)
45
+ end
46
+
47
+ # Internal method on the tracer pipeline.
48
+ # @api private
49
+ def warn(message)
50
+ @out.puts message if @log_level.between?(1, 3)
51
+ end
52
+
53
+ # Internal method on the tracer pipeline.
54
+ # @api private
55
+ def error(message)
56
+ @out.puts message if @log_level.between?(1, 4)
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,78 @@
1
+ # Rails integration
2
+
3
+ Loaded by `require 'testsift/rails'`. Exposes the Rails preset, the
4
+ Rails-side observer family, and a Railtie that integrates with the Rails
5
+ lifecycle when Rails is present in the process.
6
+
7
+ ## Surface
8
+
9
+ - **`TestSift::Rails::Preset`** - closed-enum glob set covering the
10
+ Coverage-invisible Rails surface (views, helpers, locales, config
11
+ YAML, schema, factories, fixtures). Opt in via `track_rails_defaults`
12
+ in `.testsift`; opt out per category via
13
+ `track_rails_defaults except: [:views, :locales]`.
14
+ - **`TestSift::Rails::Railtie`** - only loaded when
15
+ `defined?(::Rails::Railtie)`. Registers a single `testsift.setup`
16
+ initializer that logs a confirmation line when Rails boots.
17
+ - **`TestSift::Rails::Notifications`** - ActiveSupport::Notifications
18
+ observer for the render_template / render_partial / render_collection
19
+ events (ActionView) plus an opt-in sql.active_record subscriber for
20
+ narrow schema attribution. Installed by `Engine.setup` when
21
+ `TestSift.rails?` is true. Emits `:template` Inputs for observed
22
+ template renders and `:notification` Inputs for schema files on the
23
+ first AR query per example.
24
+ - **`TestSift::Rails::I18nTracking`** - prepends onto
25
+ `::I18n::Backend::Base#load_translations` so every I18n backend
26
+ (including custom Redis/DB/Chain backends that bypass
27
+ `YAML.load_file`) emits `:notification` Inputs for the translation
28
+ files it loads.
29
+
30
+ ## Detection
31
+
32
+ `TestSift.rails?` returns true when `::Rails::VERSION` is defined at
33
+ the time `TestSift.start` runs. The flag is computed once during
34
+ `initial_setup`; subsequent Rails loads do not flip it.
35
+
36
+ ## Zero-cost when Rails is absent
37
+
38
+ `require 'testsift/rails'` loads Preset unconditionally and skips
39
+ the Railtie via `defined?(::Rails::Railtie)`. Notifications and
40
+ I18nTracking are required lazily by `Engine.setup` only when
41
+ `TestSift.rails?` is truthy. A pure-Ruby suite that accidentally
42
+ requires the file never pays for Rails-specific code.
43
+
44
+ ## Narrow schema attribution (opt-in)
45
+
46
+ By default, `track_rails_defaults` attaches `db/schema.rb` and
47
+ `db/structure.sql` to every example via the Preset's `:schema`
48
+ declared-glob - a conservative whole-suite signal. Teams that want
49
+ schema changes to re-run only examples that actually touched AR can
50
+ opt into an `sql.active_record` subscriber:
51
+
52
+ ```ruby
53
+ TestSift.configure do
54
+ track_rails_defaults except: [:schema]
55
+ track_ar_schema_notifications
56
+ end
57
+ ```
58
+
59
+ On the first `sql.active_record` event inside an example, the
60
+ subscriber emits `:notification` Inputs for `db/schema.rb` and
61
+ `db/structure.sql` if they exist under the project root, then
62
+ short-circuits for the remainder of that example. A non-DB-touching
63
+ example never sees the schema inputs and is not re-run on schema edits.
64
+
65
+ Leaving `:schema` in `track_rails_defaults` while also enabling the
66
+ subscriber is a no-op in terms of the re-run set - declared-glob
67
+ dominates at graph registration.
68
+
69
+ ## Components
70
+
71
+ - **Preset + detection + Railtie scaffold.**
72
+ - **Notifications + I18nTracking observers**, the
73
+ `track_ar_schema_notifications` opt-in DSL, and `Engine.setup`
74
+ wiring. Factory and fixture coverage ride the existing
75
+ `LoadedFilesTracker` / `YAML.load_file` hook surface.
76
+ - **Integration coverage** against the reference Rails app
77
+ (`spec/fixtures/rails_app/`) verifying the full "change X -> Y
78
+ re-runs" behavior matrix.