rspec-hermetic 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +166 -0
- data/lib/rspec/hermetic/allowlist.rb +75 -0
- data/lib/rspec/hermetic/candidate_report.rb +47 -0
- data/lib/rspec/hermetic/change.rb +61 -0
- data/lib/rspec/hermetic/configuration.rb +93 -0
- data/lib/rspec/hermetic/corpus_evaluation.rb +124 -0
- data/lib/rspec/hermetic/diff.rb +63 -0
- data/lib/rspec/hermetic/evaluation.rb +184 -0
- data/lib/rspec/hermetic/evaluation_task.rb +53 -0
- data/lib/rspec/hermetic/forensic.rb +22 -0
- data/lib/rspec/hermetic/formatter.rb +93 -0
- data/lib/rspec/hermetic/minitest.rb +80 -0
- data/lib/rspec/hermetic/probe/base.rb +17 -0
- data/lib/rspec/hermetic/probe/constants.rb +87 -0
- data/lib/rspec/hermetic/probe/env.rb +15 -0
- data/lib/rspec/hermetic/probe/filesystem.rb +109 -0
- data/lib/rspec/hermetic/probe/globals.rb +31 -0
- data/lib/rspec/hermetic/probe/rails.rb +146 -0
- data/lib/rspec/hermetic/probe/randomness.rb +78 -0
- data/lib/rspec/hermetic/probe/resources.rb +110 -0
- data/lib/rspec/hermetic/probe/ruby_runtime.rb +37 -0
- data/lib/rspec/hermetic/probe/time.rb +54 -0
- data/lib/rspec/hermetic/probe.rb +38 -0
- data/lib/rspec/hermetic/resource_tracker.rb +111 -0
- data/lib/rspec/hermetic/restorer.rb +330 -0
- data/lib/rspec/hermetic/runner.rb +183 -0
- data/lib/rspec/hermetic/snapshot.rb +37 -0
- data/lib/rspec/hermetic/stable_value.rb +140 -0
- data/lib/rspec/hermetic/verdict.rb +39 -0
- data/lib/rspec/hermetic/verify_task.rb +65 -0
- data/lib/rspec/hermetic/version.rb +11 -0
- data/lib/rspec/hermetic.rb +59 -0
- data/sig/rspec/hermetic.rbs +112 -0
- metadata +117 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: dabaae0436d65cd41bd97891a144263792b4898dc2e4788bd0bdcbea11fb8dda
|
|
4
|
+
data.tar.gz: d4cab7a93d453f008e32a5a16421b0ec22f1eb4570c27f5e82251a86ba474d6f
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: e71317f84c4f592a2246942f15f2623d770f0d805a2bfca6737dc959874cbb3636ac9dd9a5597016666c2f8ea6fc2f88a5bac638ac3b2002dfeb980fd7b9a0d4
|
|
7
|
+
data.tar.gz: 6a54c3c931a80d52c95ae8b37bb0491b8add9d4425384f9063da5df9365de203dddc612cae32928c92ec2c530c0e6bc3d7ca668aa38560fc4dfe6aada8577355
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yudai Takada
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# rspec-hermetic
|
|
2
|
+
|
|
3
|
+
`rspec-hermetic` detects examples that leave process-global state changed after
|
|
4
|
+
they finish. It snapshots selected probes before and after each example, diffs
|
|
5
|
+
the snapshots, applies allowlists, and reports the example as a polluter when
|
|
6
|
+
state is still dirty.
|
|
7
|
+
|
|
8
|
+
The implementation follows the PolDet idea from state-pollution testing:
|
|
9
|
+
compare shared state around one test and show the before/after evidence.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
Install the gem and add it to the application's Gemfile:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
bundle add rspec-hermetic
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Or install from a local checkout:
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
gem "rspec-hermetic", path: "../rspec-hermetic"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
Configure it in `spec/spec_helper.rb`:
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
require "rspec/hermetic"
|
|
31
|
+
|
|
32
|
+
RSpec.configure do |config|
|
|
33
|
+
RSpec::Hermetic.configure(config) do |hermetic|
|
|
34
|
+
hermetic.probes = %i[env constants globals ruby_runtime rails time randomness resources]
|
|
35
|
+
hermetic.on_pollution = :risky # :risky, :fail, or :report
|
|
36
|
+
hermetic.constant_namespaces = [MyApp]
|
|
37
|
+
hermetic.filesystem_paths = ["app", "config"]
|
|
38
|
+
hermetic.randomness_seed_probe = true # opt in to Kernel.srand seed sampling
|
|
39
|
+
hermetic.resource_process_probe = true # opt in to child process scanning
|
|
40
|
+
hermetic.auto_reset = false # true or probe list, e.g. %i[env rails time]
|
|
41
|
+
hermetic.candidate_report_path = "tmp/rspec_hermetic_candidates.json"
|
|
42
|
+
|
|
43
|
+
hermetic.allow do |allow|
|
|
44
|
+
allow.env "CI", "RAILS_*"
|
|
45
|
+
allow.path "tmp/**", "log/test.log"
|
|
46
|
+
allow.constant(/\AFactoryBot::/)
|
|
47
|
+
allow.append_only "Warning.deduplicated"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Use metadata for intentional pollution in one example:
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
it "changes locale globally", hermetic: { allow: [:i18n_locale] } do
|
|
57
|
+
I18n.locale = :ja
|
|
58
|
+
end
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Available probes:
|
|
62
|
+
|
|
63
|
+
- `env`: `ENV.to_h`
|
|
64
|
+
- `constants`: configured namespaces, or top-level constants by default
|
|
65
|
+
using bounded ObjectSpace reachable-object fingerprints
|
|
66
|
+
- `globals`: selected Ruby globals such as `$LOAD_PATH` and stdio
|
|
67
|
+
- `ruby_runtime`: warning, encoding, GC, and thread runtime flags
|
|
68
|
+
- `rails`: I18n, Time.zone, Rails.cache, mailer/job counts, CurrentAttributes
|
|
69
|
+
- `time`: wall-clock/monotonic offset, ActiveSupport time helper state, and
|
|
70
|
+
Timecop travel state when present
|
|
71
|
+
- `randomness`: Random/FactoryBot state that can be read safely, with optional
|
|
72
|
+
`Kernel.srand` seed sampling via `randomness_seed_probe`
|
|
73
|
+
- `resources`: live threads, open IO objects, ActiveRecord pool state, and
|
|
74
|
+
optional child process scanning via `resource_process_probe`
|
|
75
|
+
- `filesystem`: configured paths under `root_path` (`.` by default when enabled),
|
|
76
|
+
with metadata and small-file SHA256 content hashes
|
|
77
|
+
|
|
78
|
+
`append_only` patterns only downgrade a change to a warning when the new value
|
|
79
|
+
preserves the old value as a prefix/subset. Replacing existing values is still
|
|
80
|
+
reported as pollution.
|
|
81
|
+
|
|
82
|
+
The RSpec hook is installed outside existing `around(:each)` hooks on RSpec 3
|
|
83
|
+
by using RSpec's hook collection when available. On unsupported RSpec versions
|
|
84
|
+
it falls back to normal `around(:each)` registration.
|
|
85
|
+
|
|
86
|
+
`auto_reset` is opt-in. Set it to `true` or a probe list to restore supported
|
|
87
|
+
state after reporting: ENV, selected globals/runtime flags, Rails globals,
|
|
88
|
+
time helpers, randomness seed, added constants, leaked threads/IO objects,
|
|
89
|
+
leaked child processes, and filesystem additions/modifications/deletions when
|
|
90
|
+
file contents were captured.
|
|
91
|
+
|
|
92
|
+
Resource origin tracking is enabled by default. It annotates resources created
|
|
93
|
+
while an example is running so leaked threads and IO objects can include the
|
|
94
|
+
creation callsite in the report.
|
|
95
|
+
|
|
96
|
+
For order-dependent failures, require forensic mode:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
bundle exec rspec --seed 1234 --require rspec/hermetic/forensic
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
It compares a failing example's starting state with the suite baseline and
|
|
103
|
+
reports the last example that changed each dirty key when known. Candidate
|
|
104
|
+
pairs are written to `candidate_report_path` as JSON.
|
|
105
|
+
|
|
106
|
+
To verify known polluter/victim candidates:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
bundle exec rake hermetic:verify[tmp/rspec_hermetic_candidates.json]
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The JSON file can be an array of objects or newline-delimited JSON:
|
|
113
|
+
|
|
114
|
+
```json
|
|
115
|
+
[
|
|
116
|
+
{ "polluter": "spec/models/user_spec.rb:12", "victim": "spec/models/user_spec.rb:48" }
|
|
117
|
+
]
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Minitest can use the same detector:
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
require "rspec/hermetic/minitest"
|
|
124
|
+
|
|
125
|
+
RSpec::Hermetic.configure_minitest do |hermetic|
|
|
126
|
+
hermetic.probes = %i[env globals ruby_runtime resources filesystem]
|
|
127
|
+
hermetic.on_pollution = :fail
|
|
128
|
+
end
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Run the built-in seeded pollution evaluation:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
bundle exec rake hermetic:evaluate[tmp/rspec_hermetic_evaluation.json]
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
The report includes probe-level seeded recall and is intended as the local
|
|
138
|
+
baseline for the evaluation workflow described in the design document.
|
|
139
|
+
|
|
140
|
+
Run an external corpus evaluation by providing commands through the environment:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
HERMETIC_BASELINE_COMMAND="bundle exec rspec" \
|
|
144
|
+
HERMETIC_COMMAND="bundle exec rspec --require rspec/hermetic/forensic" \
|
|
145
|
+
HERMETIC_CANDIDATES="tmp/rspec_hermetic_candidates.json" \
|
|
146
|
+
bundle exec rake hermetic:evaluate_corpus[tmp/rspec_hermetic_corpus.json]
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
If `HERMETIC_JUDGMENTS` points to a JSON file keyed by
|
|
150
|
+
`polluter|victim|probe|key`, the corpus report includes false-alarm counts and
|
|
151
|
+
rates alongside overhead and candidate counts.
|
|
152
|
+
|
|
153
|
+
## Development
|
|
154
|
+
|
|
155
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then run
|
|
156
|
+
`rake spec` to run the tests. You can also run `bin/console` for an interactive
|
|
157
|
+
prompt.
|
|
158
|
+
|
|
159
|
+
## Contributing
|
|
160
|
+
|
|
161
|
+
Bug reports and pull requests are welcome on GitHub at
|
|
162
|
+
https://github.com/ydah/rspec-hermetic.
|
|
163
|
+
|
|
164
|
+
## License
|
|
165
|
+
|
|
166
|
+
The gem is available as open source under the terms of the MIT License.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module RSpec
|
|
6
|
+
module Hermetic
|
|
7
|
+
class Allowlist
|
|
8
|
+
attr_reader :env_patterns, :path_patterns, :constant_patterns, :append_only_patterns
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@env_patterns = []
|
|
12
|
+
@path_patterns = []
|
|
13
|
+
@constant_patterns = []
|
|
14
|
+
@append_only_patterns = []
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def env(*patterns)
|
|
18
|
+
@env_patterns.concat(patterns)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def path(*patterns)
|
|
22
|
+
@path_patterns.concat(patterns)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def constant(*patterns)
|
|
26
|
+
@constant_patterns.concat(patterns)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def append_only(*patterns)
|
|
30
|
+
@append_only_patterns.concat(patterns)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def allowed?(change, example_allowances)
|
|
34
|
+
allowed_by_metadata?(change, example_allowances) ||
|
|
35
|
+
allowed_env?(change) ||
|
|
36
|
+
allowed_path?(change) ||
|
|
37
|
+
allowed_constant?(change)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def append_only?(change)
|
|
41
|
+
pattern_match?(append_only_patterns, change.key.to_s)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def allowed_by_metadata?(change, example_allowances)
|
|
47
|
+
allowances = Array(example_allowances).map(&:to_sym)
|
|
48
|
+
(change.token_candidates & allowances).any?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def allowed_env?(change)
|
|
52
|
+
change.probe == :env && pattern_match?(env_patterns, change.key.to_s)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def allowed_path?(change)
|
|
56
|
+
change.probe == :filesystem && pattern_match?(path_patterns, change.key.to_s)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def allowed_constant?(change)
|
|
60
|
+
change.probe == :constants && pattern_match?(constant_patterns, change.key.to_s)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def pattern_match?(patterns, value)
|
|
64
|
+
patterns.any? do |pattern|
|
|
65
|
+
case pattern
|
|
66
|
+
when Regexp
|
|
67
|
+
pattern.match?(value)
|
|
68
|
+
else
|
|
69
|
+
File.fnmatch?(pattern.to_s, value, File::FNM_PATHNAME | File::FNM_EXTGLOB)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module RSpec
|
|
7
|
+
module Hermetic
|
|
8
|
+
class CandidateReport
|
|
9
|
+
def initialize(path)
|
|
10
|
+
@path = path
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def append(candidate)
|
|
14
|
+
candidates = read
|
|
15
|
+
candidates << candidate
|
|
16
|
+
write(candidates)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def read
|
|
20
|
+
return [] unless @path && File.exist?(@path)
|
|
21
|
+
|
|
22
|
+
content = File.read(@path)
|
|
23
|
+
return [] if content.strip.empty?
|
|
24
|
+
|
|
25
|
+
parsed = JSON.parse(content)
|
|
26
|
+
parsed.is_a?(Array) ? parsed : [parsed]
|
|
27
|
+
rescue JSON::ParserError
|
|
28
|
+
read_json_lines
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def read_json_lines
|
|
34
|
+
File.readlines(@path, chomp: true).filter_map do |line|
|
|
35
|
+
next if line.strip.empty?
|
|
36
|
+
|
|
37
|
+
JSON.parse(line)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def write(candidates)
|
|
42
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
43
|
+
File.write(@path, "#{JSON.pretty_generate(candidates)}\n")
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Hermetic
|
|
5
|
+
MISSING_VALUE = Object.new.freeze
|
|
6
|
+
|
|
7
|
+
Change = Struct.new(:probe, :key, :before, :after, keyword_init: true) do
|
|
8
|
+
def addition?
|
|
9
|
+
before.equal?(self.class::MISSING) && !after.equal?(self.class::MISSING)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def removal?
|
|
13
|
+
after.equal?(self.class::MISSING) && !before.equal?(self.class::MISSING)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def mutation?
|
|
17
|
+
!addition? && !removal?
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def token_candidates
|
|
21
|
+
[
|
|
22
|
+
probe.to_sym,
|
|
23
|
+
key.to_s.tr(".[]\"$/:-", "_").gsub(/__+/, "_").downcase.to_sym,
|
|
24
|
+
"#{probe}:#{key}".to_sym
|
|
25
|
+
]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def append_only?
|
|
29
|
+
return true if addition?
|
|
30
|
+
return false if removal?
|
|
31
|
+
|
|
32
|
+
append_only_value?(before, after)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def append_only_value?(before_value, after_value)
|
|
38
|
+
if wrapped_value?(before_value) && wrapped_value?(after_value)
|
|
39
|
+
return append_only_value?(before_value[:value], after_value[:value])
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
if before_value.is_a?(String) && after_value.is_a?(String)
|
|
43
|
+
after_value.start_with?(before_value)
|
|
44
|
+
elsif before_value.is_a?(Array) && after_value.is_a?(Array)
|
|
45
|
+
after_value.length >= before_value.length && after_value.first(before_value.length) == before_value
|
|
46
|
+
elsif before_value.is_a?(Hash) && after_value.is_a?(Hash)
|
|
47
|
+
(before_value.keys - after_value.keys).empty? &&
|
|
48
|
+
before_value.all? { |key, value| after_value[key] == value }
|
|
49
|
+
else
|
|
50
|
+
false
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def wrapped_value?(value)
|
|
55
|
+
value.is_a?(Hash) && value.key?(:value) && value.key?(:reachable_graph)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
Change.const_set(:MISSING, MISSING_VALUE)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "allowlist"
|
|
4
|
+
|
|
5
|
+
module RSpec
|
|
6
|
+
module Hermetic
|
|
7
|
+
class Configuration
|
|
8
|
+
attr_accessor :probes,
|
|
9
|
+
:on_pollution,
|
|
10
|
+
:root_path,
|
|
11
|
+
:constant_namespaces,
|
|
12
|
+
:constant_exclude_patterns,
|
|
13
|
+
:constants_max_depth,
|
|
14
|
+
:constants_max_entries,
|
|
15
|
+
:constants_graph,
|
|
16
|
+
:constants_graph_max_depth,
|
|
17
|
+
:constants_graph_max_nodes,
|
|
18
|
+
:filesystem_paths,
|
|
19
|
+
:filesystem_exclude_patterns,
|
|
20
|
+
:filesystem_content_hash_bytes,
|
|
21
|
+
:filesystem_max_entries,
|
|
22
|
+
:filesystem_max_depth,
|
|
23
|
+
:forensic,
|
|
24
|
+
:randomness_seed_probe,
|
|
25
|
+
:resource_process_probe,
|
|
26
|
+
:rails_config_paths,
|
|
27
|
+
:probe_sampling,
|
|
28
|
+
:report_probe_errors,
|
|
29
|
+
:candidate_report_path,
|
|
30
|
+
:auto_reset,
|
|
31
|
+
:track_resource_origins
|
|
32
|
+
attr_reader :allowlist
|
|
33
|
+
|
|
34
|
+
def initialize
|
|
35
|
+
@probes = %i[env constants globals ruby_runtime rails time randomness resources]
|
|
36
|
+
@on_pollution = :risky
|
|
37
|
+
@root_path = Dir.pwd
|
|
38
|
+
@constant_namespaces = []
|
|
39
|
+
@constant_exclude_patterns = []
|
|
40
|
+
@constants_max_depth = 0
|
|
41
|
+
@constants_max_entries = 1_000
|
|
42
|
+
@constants_graph = true
|
|
43
|
+
@constants_graph_max_depth = 3
|
|
44
|
+
@constants_graph_max_nodes = 1_000
|
|
45
|
+
@filesystem_paths = ["."]
|
|
46
|
+
@filesystem_exclude_patterns = %w[
|
|
47
|
+
.git
|
|
48
|
+
.git/**
|
|
49
|
+
.bundle
|
|
50
|
+
.bundle/**
|
|
51
|
+
vendor/bundle
|
|
52
|
+
vendor/bundle/**
|
|
53
|
+
node_modules
|
|
54
|
+
node_modules/**
|
|
55
|
+
]
|
|
56
|
+
@filesystem_content_hash_bytes = 64 * 1024
|
|
57
|
+
@filesystem_max_entries = 1_000
|
|
58
|
+
@filesystem_max_depth = nil
|
|
59
|
+
@forensic = false
|
|
60
|
+
@randomness_seed_probe = false
|
|
61
|
+
@resource_process_probe = false
|
|
62
|
+
@rails_config_paths = %w[
|
|
63
|
+
cache_store
|
|
64
|
+
time_zone
|
|
65
|
+
active_job.queue_adapter
|
|
66
|
+
action_mailer.delivery_method
|
|
67
|
+
action_controller.allow_forgery_protection
|
|
68
|
+
i18n.default_locale
|
|
69
|
+
]
|
|
70
|
+
@probe_sampling = {}
|
|
71
|
+
@report_probe_errors = true
|
|
72
|
+
@candidate_report_path = "tmp/rspec_hermetic_candidates.json"
|
|
73
|
+
@auto_reset = false
|
|
74
|
+
@track_resource_origins = true
|
|
75
|
+
@allowlist = Allowlist.new
|
|
76
|
+
@allowlist.path "tmp/**", "log/**", ".rspec_status", "coverage/**"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def allow
|
|
80
|
+
yield allowlist
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def probe_interval(name)
|
|
84
|
+
interval = probe_sampling.fetch(name.to_sym, 1).to_i
|
|
85
|
+
interval.positive? ? interval : 1
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def auto_reset_probe?(name)
|
|
89
|
+
auto_reset == true || Array(auto_reset).map(&:to_sym).include?(name.to_sym)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "open3"
|
|
6
|
+
require "time"
|
|
7
|
+
|
|
8
|
+
require_relative "candidate_report"
|
|
9
|
+
|
|
10
|
+
module RSpec
|
|
11
|
+
module Hermetic
|
|
12
|
+
class CorpusEvaluation
|
|
13
|
+
def initialize(
|
|
14
|
+
output_path:,
|
|
15
|
+
project_path: Dir.pwd,
|
|
16
|
+
baseline_command: nil,
|
|
17
|
+
hermetic_command:,
|
|
18
|
+
candidate_report_path: "tmp/rspec_hermetic_candidates.json",
|
|
19
|
+
judgments_path: nil
|
|
20
|
+
)
|
|
21
|
+
@output_path = output_path
|
|
22
|
+
@project_path = project_path
|
|
23
|
+
@baseline_command = baseline_command
|
|
24
|
+
@hermetic_command = hermetic_command
|
|
25
|
+
@candidate_report_path = candidate_report_path
|
|
26
|
+
@judgments_path = judgments_path
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def run
|
|
30
|
+
baseline = run_command(@baseline_command) if @baseline_command
|
|
31
|
+
hermetic = run_command(@hermetic_command)
|
|
32
|
+
candidates = read_candidates
|
|
33
|
+
report = {
|
|
34
|
+
"project_path" => @project_path,
|
|
35
|
+
"generated_at" => Time.now.utc.iso8601,
|
|
36
|
+
"baseline" => baseline,
|
|
37
|
+
"hermetic" => hermetic,
|
|
38
|
+
"overhead" => overhead(baseline, hermetic),
|
|
39
|
+
"candidates" => candidates,
|
|
40
|
+
"candidate_count" => candidates.length,
|
|
41
|
+
"false_alarm" => false_alarm_summary(candidates)
|
|
42
|
+
}.compact
|
|
43
|
+
write(report)
|
|
44
|
+
report
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def run_command(command)
|
|
50
|
+
started_at = monotonic_now
|
|
51
|
+
stdout, stderr, status = Open3.capture3(command, chdir: @project_path)
|
|
52
|
+
{
|
|
53
|
+
"command" => command,
|
|
54
|
+
"status" => status.exitstatus,
|
|
55
|
+
"success" => status.success?,
|
|
56
|
+
"duration_seconds" => monotonic_now - started_at,
|
|
57
|
+
"stdout_tail" => tail(stdout),
|
|
58
|
+
"stderr_tail" => tail(stderr)
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def read_candidates
|
|
63
|
+
path = File.expand_path(@candidate_report_path, @project_path)
|
|
64
|
+
return [] unless File.exist?(path)
|
|
65
|
+
|
|
66
|
+
CandidateReport.new(path).read
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def false_alarm_summary(candidates)
|
|
70
|
+
judgments = read_judgments
|
|
71
|
+
return { "judged" => 0, "false_alarms" => nil, "rate" => nil } if judgments.empty?
|
|
72
|
+
|
|
73
|
+
judged = candidates.filter_map { |candidate| judgment_for(judgments, candidate) }
|
|
74
|
+
false_alarms = judged.count { |judgment| judgment == "false_alarm" }
|
|
75
|
+
{
|
|
76
|
+
"judged" => judged.length,
|
|
77
|
+
"false_alarms" => false_alarms,
|
|
78
|
+
"rate" => judged.empty? ? nil : false_alarms.fdiv(judged.length)
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def read_judgments
|
|
83
|
+
return {} unless @judgments_path && File.exist?(@judgments_path)
|
|
84
|
+
|
|
85
|
+
JSON.parse(File.read(@judgments_path))
|
|
86
|
+
rescue JSON::ParserError
|
|
87
|
+
{}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def judgment_for(judgments, candidate)
|
|
91
|
+
judgments[candidate_key(candidate)] ||
|
|
92
|
+
judgments[[candidate["polluter"], candidate["victim"], candidate["probe"], candidate["key"]].join("|")]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def candidate_key(candidate)
|
|
96
|
+
candidate.fetch("id") do
|
|
97
|
+
[candidate["polluter"], candidate["victim"], candidate["probe"], candidate["key"]].join("|")
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def overhead(baseline, hermetic)
|
|
102
|
+
return nil unless baseline && hermetic && baseline["duration_seconds"].positive?
|
|
103
|
+
|
|
104
|
+
{
|
|
105
|
+
"seconds" => hermetic["duration_seconds"] - baseline["duration_seconds"],
|
|
106
|
+
"ratio" => hermetic["duration_seconds"].fdiv(baseline["duration_seconds"])
|
|
107
|
+
}
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def write(report)
|
|
111
|
+
FileUtils.mkdir_p(File.dirname(@output_path))
|
|
112
|
+
File.write(@output_path, "#{JSON.pretty_generate(report)}\n")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def tail(text)
|
|
116
|
+
text.lines.last(50).join
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def monotonic_now
|
|
120
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "change"
|
|
4
|
+
|
|
5
|
+
module RSpec
|
|
6
|
+
module Hermetic
|
|
7
|
+
class ChangeSet
|
|
8
|
+
attr_reader :changes, :before_errors, :after_errors, :before_timings, :after_timings
|
|
9
|
+
|
|
10
|
+
def initialize(changes:, before_errors:, after_errors:, before_timings:, after_timings:)
|
|
11
|
+
@changes = changes
|
|
12
|
+
@before_errors = before_errors
|
|
13
|
+
@after_errors = after_errors
|
|
14
|
+
@before_timings = before_timings
|
|
15
|
+
@after_timings = after_timings
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def empty?
|
|
19
|
+
changes.empty?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def errors?
|
|
23
|
+
before_errors.any? || after_errors.any?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def timings
|
|
27
|
+
probe_names = before_timings.keys | after_timings.keys
|
|
28
|
+
probe_names.to_h do |probe|
|
|
29
|
+
[probe, before_timings.fetch(probe, 0.0) + after_timings.fetch(probe, 0.0)]
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
class Diff
|
|
35
|
+
def self.between(before_snapshot, after_snapshot)
|
|
36
|
+
changes = []
|
|
37
|
+
probe_names = before_snapshot.data.keys | after_snapshot.data.keys
|
|
38
|
+
|
|
39
|
+
probe_names.each do |probe|
|
|
40
|
+
before_data = before_snapshot.data.fetch(probe, {})
|
|
41
|
+
after_data = after_snapshot.data.fetch(probe, {})
|
|
42
|
+
keys = before_data.keys | after_data.keys
|
|
43
|
+
|
|
44
|
+
keys.each do |key|
|
|
45
|
+
before_value = before_data.key?(key) ? before_data[key] : Change::MISSING
|
|
46
|
+
after_value = after_data.key?(key) ? after_data[key] : Change::MISSING
|
|
47
|
+
next if before_value == after_value
|
|
48
|
+
|
|
49
|
+
changes << Change.new(probe: probe, key: key, before: before_value, after: after_value)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
ChangeSet.new(
|
|
54
|
+
changes: changes,
|
|
55
|
+
before_errors: before_snapshot.errors,
|
|
56
|
+
after_errors: after_snapshot.errors,
|
|
57
|
+
before_timings: before_snapshot.timings,
|
|
58
|
+
after_timings: after_snapshot.timings
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|