selective-ruby-core 0.2.8 → 0.2.10

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.
@@ -16,15 +16,17 @@ module Selective
16
16
  "platform" => "SELECTIVE_PLATFORM",
17
17
  "run_id" => "SELECTIVE_RUN_ID",
18
18
  "run_attempt" => "SELECTIVE_RUN_ATTEMPT",
19
- "branch" => "SELECTIVE_BRANCH"
19
+ "branch" => "SELECTIVE_BRANCH",
20
+ "sha" => "SELECTIVE_SHA",
21
+ "git_repo_full_name" => "SELECTIVE_GIT_REPO",
22
+ "git_provider" => "SELECTIVE_GIT_PROVIDER"
20
23
  }.freeze
21
24
 
22
25
  def initialize(runner_class, runner_args, debug: false, log: false)
23
26
  @debug = debug
27
+ @log = log
24
28
  @runner = runner_class.new(runner_args, method(:test_case_callback))
25
29
  @retries = 0
26
- @runner_id = safe_filename(get_runner_id)
27
- @logger = init_logger(log)
28
30
  end
29
31
 
30
32
  def start(reconnect: false)
@@ -64,7 +66,19 @@ module Selective
64
66
 
65
67
  private
66
68
 
67
- attr_reader :runner, :pipe, :transport_pid, :retries, :logger, :runner_id, :diff
69
+ attr_reader :runner, :pipe, :transport_pid, :retries, :diff
70
+
71
+ # Resolved on first use rather than in the constructor: `selective
72
+ # exec` (the plain framework run behind the manifest dry-run) never
73
+ # connects, so it must not require the CI environment that naming a
74
+ # runner validates.
75
+ def runner_id
76
+ @runner_id ||= safe_filename(get_runner_id)
77
+ end
78
+
79
+ def logger
80
+ @logger ||= init_logger(@log)
81
+ end
68
82
 
69
83
  def get_runner_id
70
84
  runner_id = build_env["runner_id"]
@@ -87,7 +101,7 @@ module Selective
87
101
  message = pipe.read
88
102
  response = JSON.parse(message, symbolize_names: true)
89
103
 
90
- @logger.info("Received Command: #{response}")
104
+ logger.info("Received Command: #{response}")
91
105
  break if handle_command(response) == :break
92
106
  end
93
107
  end
@@ -137,6 +151,13 @@ module Selective
137
151
  "framework" => runner.framework,
138
152
  "framework_version" => runner.framework_version,
139
153
  "framework_wrapper_version" => runner.wrapper_version,
154
+ "runtime_version" => RUBY_VERSION,
155
+ # "record", "select" or "0": whether this runner can record a
156
+ # test map, only run selected subsets, or neither.
157
+ "test_map" => TestMap.capability,
158
+ # Forces this run to record (e.g. a nightly full run), however
159
+ # fresh the current map is.
160
+ "test_map_record" => ENV["SELECTIVE_TEST_MAP_RECORD"].to_s
140
161
  }.merge(metadata: metadata.to_json)
141
162
  end
142
163
  end
@@ -268,8 +289,11 @@ module Selective
268
289
 
269
290
  def handle_test_manifest(_data)
270
291
  self.class.restore_reporting!
271
- @logger.info("Sending Response: test_manifest")
292
+ logger.info("Sending Response: test_manifest")
272
293
  data = {test_cases: runner.manifest["examples"] || runner.manifest["test_cases"]}
294
+ if TestMap.tree_requested? && (tree = test_map_tree)
295
+ data[:test_map_tree] = tree
296
+ end
273
297
  num_commits = build_env["num_commits"] || 1000
274
298
  if (diff = get_diff(num_commits))
275
299
  data[:modified_test_files] = modified_test_files(diff)
@@ -278,6 +302,22 @@ module Selective
278
302
  write({type: "test_manifest", data: data})
279
303
  end
280
304
 
305
+ # Sent by servers that support test maps, before any tests are
306
+ # assigned. `tree: true` asks for a file-hash snapshot with the
307
+ # manifest; `record: true` turns on tracing for every test this runner
308
+ # executes.
309
+ def handle_configure_test_map(data)
310
+ recorder = TestMap.configure(
311
+ record: data[:record] == true,
312
+ tree: data[:tree] == true,
313
+ allocations: data[:allocations] != false
314
+ )
315
+ logger.info("Test map configured: record=#{!recorder.nil?} tree=#{TestMap.tree_requested?}")
316
+ if data[:record] == true && recorder.nil?
317
+ print_warning("Selective asked this runner to record a test map, but the native tracer isn't available. Tests will run normally.")
318
+ end
319
+ end
320
+
281
321
  def handle_run_test_cases(data)
282
322
  runner.run_test_cases(data[:test_case_ids])
283
323
  end
@@ -304,6 +344,8 @@ module Selective
304
344
  runner.finish unless exit_status.is_a?(Integer)
305
345
  end
306
346
 
347
+ upload_test_map
348
+
307
349
  kill_transport
308
350
  pipe.delete_pipes
309
351
  exit(exit_status || runner.exit_status)
@@ -312,12 +354,40 @@ module Selective
312
354
  :break
313
355
  end
314
356
 
357
+ def test_map_tree
358
+ TestMap::Tree.snapshot
359
+ rescue => e
360
+ logger.warn("Could not snapshot the repository for test selection: #{e.message}")
361
+ nil
362
+ end
363
+
364
+ def upload_test_map
365
+ recorder = TestMap.recorder
366
+ return if recorder.nil? || recorder.empty?
367
+
368
+ uploader = TestMap::Uploader.new(
369
+ host: build_env["host"],
370
+ api_key: build_env["api_key"],
371
+ run_id: build_env["run_id"],
372
+ run_attempt: build_env["run_attempt"],
373
+ runner_id: runner_id,
374
+ logger: logger
375
+ )
376
+ if uploader.upload(recorder.to_h)
377
+ logger.info("Uploaded test map fragment: #{recorder.test_count} tests")
378
+ else
379
+ print_warning("Selective could not upload this runner's test map (#{uploader.last_error&.message}). Its tests will run on the next build rather than be skipped.")
380
+ end
381
+ rescue => e
382
+ logger.warn("Test map upload failed: #{e.class}: #{e.message}")
383
+ end
384
+
315
385
  def correlated_files(diff, num_commits)
316
386
  Selective::Ruby::Core::FileCorrelator.new(diff, num_commits, build_env["target_branch"]).correlate
317
387
  end
318
388
 
319
389
  def test_case_callback(test_case)
320
- @logger.info("Sending Response: test_case_result: #{test_case[:id]}")
390
+ logger.info("Sending Response: test_case_result: #{test_case[:id]}")
321
391
  write({type: "test_case_result", data: test_case})
322
392
  end
323
393
 
File without changes
File without changes
File without changes
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Selective
6
+ module Ruby
7
+ module Core
8
+ module TestMap
9
+ # Accumulates "test id => files" for every test this runner executes and
10
+ # serialises it as a fragment. Paths are interned into a table so a
11
+ # fragment carries each path once, not once per test that touched it.
12
+ class Recorder
13
+ FORMAT_VERSION = 1
14
+
15
+ attr_reader :tracer
16
+
17
+ def initialize(tracer:)
18
+ @tracer = tracer
19
+ @paths = []
20
+ @path_index = {}
21
+ @tests = {}
22
+ end
23
+
24
+ # Retries and reruns can run a test twice in one process. Keep the
25
+ # union: a dependency seen on any attempt is a real dependency.
26
+ def record(test_id)
27
+ result, files = tracer.trace { yield }
28
+ indexes = (@tests[test_id] ||= Set.new)
29
+ files.each { |path| indexes << intern(path) }
30
+ result
31
+ end
32
+
33
+ def test_count
34
+ @tests.size
35
+ end
36
+
37
+ def empty?
38
+ @tests.empty?
39
+ end
40
+
41
+ def to_h
42
+ {
43
+ version: FORMAT_VERSION,
44
+ files: @paths,
45
+ tests: @tests.map { |id, indexes| {id: id, deps: indexes.to_a.sort} }
46
+ }
47
+ end
48
+
49
+ private
50
+
51
+ def intern(path)
52
+ @path_index[path] ||= begin
53
+ @paths << path
54
+ @paths.size - 1
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Selective
6
+ module Ruby
7
+ module Core
8
+ module TestMap
9
+ # Ruby face of the native tracer: decides which paths count as the
10
+ # project's own code, and turns what the tracer saw into sorted,
11
+ # repo-relative paths that line up with `git ls-tree`.
12
+ class Tracer
13
+ attr_reader :repo_root
14
+
15
+ def initialize(allocations: true, repo_root: Tree.repo_root, pwd: Dir.pwd)
16
+ @repo_root = repo_root || File.realpath(pwd)
17
+ @roots = unique_dirs([@repo_root, pwd])
18
+ # Only directories *inside* a root need ignoring (everything else is
19
+ # outside the roots already). An ancestor would ignore the whole
20
+ # project, e.g. GEM_HOME=/work with the repo at /work/app.
21
+ @ignored = unique_dirs(ignored_dirs).select do |dir|
22
+ @roots.any? { |root| dir.start_with?("#{root}/") }
23
+ end
24
+ @native = NativeTracer.new(roots: @roots, ignored: @ignored, allocations: allocations)
25
+ @relative_paths = {}
26
+ @class_files = {}.compare_by_identity
27
+ end
28
+
29
+ # Runs the block with tracing on and returns [result, files], where
30
+ # files is a Set of repo-relative paths.
31
+ def trace
32
+ @native.start
33
+ begin
34
+ result = yield
35
+ ensure
36
+ files, klasses = @native.stop
37
+ end
38
+ [result, collect(files, klasses)]
39
+ end
40
+
41
+ private
42
+
43
+ def collect(files, klasses)
44
+ paths = Set.new
45
+ files.each_key do |path|
46
+ rel = relative(path)
47
+ paths << rel if rel
48
+ end
49
+ klasses.each do |klass|
50
+ files_for_class(klass).each { |rel| paths << rel }
51
+ end
52
+ paths
53
+ end
54
+
55
+ # A class's own file, plus those of its project-defined ancestors: a
56
+ # test that builds a `User` depends on `ApplicationRecord` too.
57
+ def files_for_class(klass)
58
+ @class_files[klass] ||= klass.ancestors.filter_map do |mod|
59
+ name = mod.name
60
+ next if name.nil?
61
+
62
+ location = Object.const_source_location(name)&.first
63
+ location && relative(location)
64
+ rescue NameError, ArgumentError, TypeError
65
+ nil
66
+ end.uniq.freeze
67
+ end
68
+
69
+ def relative(path)
70
+ return @relative_paths[path] if @relative_paths.key?(path)
71
+
72
+ @relative_paths[path] = compute_relative(path)
73
+ end
74
+
75
+ def compute_relative(path)
76
+ abs = File.expand_path(path)
77
+ return nil if ignored?(abs)
78
+
79
+ rel = strip_root(abs)
80
+ if rel.nil? && File.exist?(abs)
81
+ real = File.realpath(abs)
82
+ rel = strip_root(real) unless ignored?(real)
83
+ end
84
+ rel
85
+ end
86
+
87
+ def strip_root(abs)
88
+ prefix = "#{@repo_root}/"
89
+ abs.start_with?(prefix) ? abs.delete_prefix(prefix) : nil
90
+ end
91
+
92
+ def ignored?(abs)
93
+ @ignored.any? { |dir| abs == dir || abs.start_with?("#{dir}/") }
94
+ end
95
+
96
+ # Code the project runs but doesn't own: installed gems (including a
97
+ # vendored bundle inside the repo) and Selective's own gems, which
98
+ # live inside the repo when developed through path/symlinks.
99
+ def ignored_dirs
100
+ dirs = [Gem.dir, *Gem.path]
101
+ dirs << Bundler.bundle_path.to_s if defined?(Bundler) && Bundler.respond_to?(:bundle_path)
102
+ Gem.loaded_specs.each do |name, spec|
103
+ dirs << spec.full_gem_path if name.start_with?("selective-ruby-")
104
+ end
105
+ extra = ENV["SELECTIVE_TEST_MAP_IGNORE"].to_s.split(",").map(&:strip).reject(&:empty?)
106
+ dirs.concat(extra.map { |d| File.expand_path(d) })
107
+ end
108
+
109
+ def unique_dirs(dirs)
110
+ dirs.compact.flat_map do |dir|
111
+ expanded = File.expand_path(dir.to_s)
112
+ real = File.exist?(expanded) ? File.realpath(expanded) : expanded
113
+ [expanded, real]
114
+ end.map { |d| d.chomp("/") }.reject(&:empty?).uniq
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Selective
6
+ module Ruby
7
+ module Core
8
+ module TestMap
9
+ # A content-hash snapshot of the repository: every file's git blob id.
10
+ #
11
+ # This is what makes selection independent of diffs. The server
12
+ # compares these hashes with the ones stored alongside the map, so there
13
+ # is no base commit to pick, no merge-base to compute and no fetch depth
14
+ # to get right. It works in a depth-1 clone, and for uncommitted edits
15
+ # (a developer or an agent running locally) it hashes what is actually
16
+ # on disk rather than what HEAD says.
17
+ module Tree
18
+ module_function
19
+
20
+ def repo_root
21
+ out, status = Open3.capture2e("git", "rev-parse", "--show-toplevel")
22
+ status.success? ? out.strip : nil
23
+ rescue SystemCallError
24
+ nil
25
+ end
26
+
27
+ # => {sha:, prefix:, files: {path => blob}, dirty: [paths]} or nil
28
+ # when the working directory isn't a git checkout.
29
+ def snapshot(pwd: Dir.pwd)
30
+ root = repo_root
31
+ return nil if root.nil?
32
+
33
+ files = committed_files(root)
34
+ return nil if files.nil?
35
+
36
+ dirty = apply_working_tree_changes(root, files)
37
+ {
38
+ sha: head_sha(root),
39
+ prefix: prefix_for(root, pwd),
40
+ files: files,
41
+ dirty: dirty
42
+ }
43
+ end
44
+
45
+ # Where the test command runs, relative to the repository root, e.g.
46
+ # "services/api/" in a monorepo. The server uses it to line manifest
47
+ # paths (relative to the command) up with tree paths (relative to the
48
+ # root).
49
+ def prefix_for(root, pwd)
50
+ real = File.realpath(pwd)
51
+ return "" if real == root
52
+
53
+ real.start_with?("#{root}/") ? "#{real.delete_prefix("#{root}/")}/" : ""
54
+ end
55
+
56
+ def head_sha(root)
57
+ git(root, "rev-parse", "HEAD")&.strip
58
+ end
59
+
60
+ def committed_files(root)
61
+ out = git(root, "ls-tree", "-r", "-z", "--full-tree", "HEAD")
62
+ return nil if out.nil?
63
+
64
+ out.split("\0").each_with_object({}) do |entry, files|
65
+ meta, path = entry.split("\t", 2)
66
+ next if path.nil?
67
+
68
+ _mode, _type, blob = meta.split(" ")
69
+ files[path] = blob
70
+ end
71
+ end
72
+
73
+ # Folds uncommitted edits to tracked files into the snapshot. Returns
74
+ # the dirty paths so the server can report that the run used a
75
+ # working tree.
76
+ #
77
+ # Untracked files are deliberately left out. They add nothing — a new
78
+ # test file is "new" to the map through the manifest, and a new app
79
+ # file can't be a recorded dependency — and they churn: runner logs,
80
+ # tmp/, coverage output. Hashing them made a local log file look like
81
+ # a changed file no test uses, which runs the whole suite.
82
+ def apply_working_tree_changes(root, files)
83
+ out = git(root, "status", "--porcelain=v1", "-z", "--untracked-files=no", "--no-renames")
84
+ return [] if out.nil? || out.empty?
85
+
86
+ changed = []
87
+ deleted = []
88
+ out.split("\0").each do |entry|
89
+ next if entry.length < 4
90
+
91
+ status = entry[0, 2]
92
+ path = entry[3..]
93
+ if status.include?("D") && !File.exist?(File.join(root, path))
94
+ files.delete(path)
95
+ deleted << path
96
+ else
97
+ changed << path
98
+ end
99
+ end
100
+
101
+ hash_objects(root, changed).each { |path, blob| files[path] = blob }
102
+ changed + deleted
103
+ end
104
+
105
+ def hash_objects(root, paths)
106
+ existing = paths.select { |p| File.file?(File.join(root, p)) }
107
+ return {} if existing.empty?
108
+
109
+ out, status = Open3.capture2("git", "-C", root, "hash-object", "--stdin-paths", stdin_data: existing.join("\n") + "\n")
110
+ return {} unless status.success?
111
+
112
+ existing.zip(out.split("\n")).to_h
113
+ end
114
+
115
+ def git(root, *args)
116
+ out, status = Open3.capture2("git", "-C", root, *args)
117
+ status.success? ? out : nil
118
+ rescue SystemCallError
119
+ nil
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "open3"
6
+ require "rbconfig"
7
+ require "tempfile"
8
+ require "uri"
9
+ require "zlib"
10
+
11
+ module Selective
12
+ module Ruby
13
+ module Core
14
+ module TestMap
15
+ # Sends this runner's recorded fragment to the server over HTTPS.
16
+ #
17
+ # A map is far larger than anything else a runner sends (tests × files),
18
+ # so it doesn't go over the WebSocket. Upload failure never fails the
19
+ # build: the worst outcome is a map missing this runner's tests, and a
20
+ # test missing from the map is always run.
21
+ #
22
+ # The POST runs in a child Ruby process. The upload happens inside the
23
+ # test process, where suites commonly load WebMock or VCR, and those
24
+ # intercept every Net::HTTP request, this one included. A child started
25
+ # without Bundler's environment loads neither.
26
+ class Uploader
27
+ PATH = "/test_maps/fragments"
28
+ ATTEMPTS = 3
29
+
30
+ def initialize(host:, api_key:, run_id:, run_attempt:, runner_id:, logger: nil)
31
+ @uri = build_uri(host, run_id: run_id, run_attempt: run_attempt, runner_id: runner_id)
32
+ @api_key = api_key
33
+ @logger = logger
34
+ end
35
+
36
+ # Returns true on success.
37
+ def upload(fragment)
38
+ body = Zlib.gzip(JSON.generate(fragment))
39
+ attempt = 0
40
+ begin
41
+ attempt += 1
42
+ true if post(body)
43
+ rescue => e
44
+ @logger&.warn("Test map upload attempt #{attempt} failed: #{e.message}")
45
+ if attempt < ATTEMPTS
46
+ sleep(attempt)
47
+ retry
48
+ end
49
+ @last_error = e
50
+ false
51
+ end
52
+ end
53
+
54
+ attr_reader :last_error, :uri
55
+
56
+ private
57
+
58
+ # The request, as a standalone script for the child process. The API key
59
+ # arrives in the environment, never on the command line.
60
+ CHILD_SCRIPT = <<~RUBY
61
+ require "net/http"
62
+ require "uri"
63
+ uri = URI(ARGV.fetch(0))
64
+ http = Net::HTTP.new(uri.host, uri.port)
65
+ http.use_ssl = uri.scheme == "https"
66
+ http.open_timeout = 10
67
+ http.read_timeout = 120
68
+ request = Net::HTTP::Post.new(uri.request_uri)
69
+ request["authorization"] = ENV.fetch("SELECTIVE_TEST_MAP_UPLOAD_KEY")
70
+ request["content-type"] = "application/gzip"
71
+ request.body = File.binread(ARGV.fetch(1))
72
+ response = http.request(request)
73
+ puts response.code
74
+ warn response.body.to_s[0, 200] unless response.is_a?(Net::HTTPSuccess)
75
+ exit(response.is_a?(Net::HTTPSuccess) ? 0 : 1)
76
+ RUBY
77
+
78
+ # Keeps Bundler (and whatever it would require) out of the child.
79
+ CLEAN_ENV = %w[RUBYOPT RUBYLIB BUNDLE_GEMFILE BUNDLE_BIN_PATH BUNDLER_SETUP BUNDLER_VERSION GEM_HOME
80
+ GEM_PATH].to_h do |k|
81
+ [k, nil]
82
+ end
83
+
84
+ # Raises unless the server accepted the upload.
85
+ def post(body)
86
+ Tempfile.create(["selective-test-map", ".json.gz"], binmode: true) do |file|
87
+ file.write(body)
88
+ file.flush
89
+
90
+ env = CLEAN_ENV.merge("SELECTIVE_TEST_MAP_UPLOAD_KEY" => @api_key.to_s)
91
+ out, err, status = Open3.capture3(env, RbConfig.ruby, "-e", CHILD_SCRIPT, @uri.to_s, file.path)
92
+ raise "HTTP #{out.strip}: #{err.strip}" unless status.success?
93
+
94
+ true
95
+ end
96
+ end
97
+
98
+ # The runner is configured with the WebSocket host (ws:// or wss://);
99
+ # the upload goes to the same server over HTTP(S).
100
+ def build_uri(host, **params)
101
+ base = host.sub(%r{\Aws(s?)://}) { "http#{::Regexp.last_match(1)}://" }.chomp("/")
102
+ URI("#{base}#{PATH}?#{URI.encode_www_form(params)}")
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Selective
4
+ module Ruby
5
+ module Core
6
+ # Test maps let Selective skip tests a change can't affect.
7
+ #
8
+ # A *recording* run traces every test with the native tracer and uploads
9
+ # "test id => repo files it executed" when the runner finishes. A later
10
+ # run sends a snapshot of the repository's file hashes with its manifest,
11
+ # and the server skips a test only when every file it depends on is
12
+ # byte-for-byte what it was when the map was recorded.
13
+ #
14
+ # Everything here is inert unless the server asks for it
15
+ # (`configure_test_map`), and recording additionally needs the optional
16
+ # native extension. Without either, the suite runs exactly as before.
17
+ module TestMap
18
+ class << self
19
+ # True when the native tracer compiled and loads on this Ruby.
20
+ def available?
21
+ return @available if defined?(@available)
22
+
23
+ @available = if disabled?
24
+ false
25
+ else
26
+ begin
27
+ require "selective_tracer"
28
+ true
29
+ rescue LoadError
30
+ false
31
+ end
32
+ end
33
+ end
34
+
35
+ # SELECTIVE_TEST_MAP_DISABLE=1 is the escape hatch: the runner then
36
+ # never records and never takes part in selection.
37
+ def disabled?
38
+ ENV["SELECTIVE_TEST_MAP_DISABLE"].to_s.match?(/\A(1|true|yes)\z/i)
39
+ end
40
+
41
+ # What this runner can do, as advertised to the server. Selection
42
+ # needs only a git snapshot, so a runner without the native tracer
43
+ # (unsupported Ruby, failed compile) still runs selected subsets
44
+ # from maps recorded elsewhere; it just can't record one.
45
+ def capability
46
+ return "0" if disabled?
47
+
48
+ available? ? "record" : "select"
49
+ end
50
+
51
+ # Recording is one-way for the life of the process: a reconnect can
52
+ # make the server re-send its configuration, and dropping a recorder
53
+ # would throw away every test traced so far.
54
+ def configure(record: false, tree: false, allocations: true)
55
+ @tree_requested = tree
56
+ if record && available?
57
+ @recorder ||= Recorder.new(tracer: Tracer.new(allocations: allocations))
58
+ end
59
+ @recorder
60
+ end
61
+
62
+ def recording?
63
+ !@recorder.nil?
64
+ end
65
+
66
+ def tree_requested?
67
+ @tree_requested == true
68
+ end
69
+
70
+ # Wraps one test. Adapters call this around the framework's own
71
+ # "run this test" so setup, the test body and teardown are all traced.
72
+ def around(test_id)
73
+ recorder = @recorder
74
+ return yield if recorder.nil?
75
+
76
+ recorder.record(test_id) { yield }
77
+ end
78
+
79
+ attr_reader :recorder
80
+
81
+ def reset!
82
+ @recorder = nil
83
+ @tree_requested = false
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -3,7 +3,7 @@
3
3
  module Selective
4
4
  module Ruby
5
5
  module Core
6
- VERSION = "0.2.8"
6
+ VERSION = "0.2.10"
7
7
  end
8
8
  end
9
9
  end
File without changes