selective-ruby-core 0.2.9 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d5d925ba84e14e58006c41c23c87f716b46f84ccd37df32b158b1340e3ad7309
4
- data.tar.gz: 43c0d8bd524fc6b59bb829c211099dd56cd6d19540ddc682abad1a40fd76298b
3
+ metadata.gz: 9f6dbb5b565e9daa33dd9734e130efafcbb431372ee74eafc3b1e61356b94f2a
4
+ data.tar.gz: 9a4252a640d2a396d6d8b6cde0817566c9940ff569bceeb049260a062f6b9e73
5
5
  SHA512:
6
- metadata.gz: 36103afe3d448cb4fb17d9bfdaebc93f3ac64a57e89b793f24df570ec107e4d99daa54e9f97935e9583c06b52ff07699b1391404d253f148f2d188c3f8c9e651
7
- data.tar.gz: 005ee1f9f38d12e87a609465b77933fb4ad3ac7a77c1aa28a7f191a3257c07df38597342f756d8318b577b5df1eef7ba1e75cbcb8b90df032b1c133b1c2c95bc
6
+ metadata.gz: 5db64c1685e6c52cf3bca4d08c27b5cc3461f2162b8e9d587fd87872c7f73012d96a14c2260d7b2e6650661e6a07163a0ed379aa6019459beb91591e4ea79dcb
7
+ data.tar.gz: 4f0755272db64264a15727ce35a1d8a896953a0f192617d9b9eff0afbe46b3d8f7f1211e11de45cc4b9987e91d6b5092a6636024e6fefe1a240e585e63da2984
data/LICENSE CHANGED
File without changes
data/Rakefile CHANGED
@@ -5,6 +5,26 @@ require "rspec/core/rake_task"
5
5
 
6
6
  RSpec::Core::RakeTask.new(:spec)
7
7
 
8
+ # Builds the optional test map tracer into lib/ for development and CI.
9
+ # Installed gems build it through `spec.extensions` instead.
10
+ desc "Compile the native test map tracer"
11
+ task :compile do
12
+ require "fileutils"
13
+ require "rbconfig"
14
+ ext_dir = File.expand_path("ext/selective_tracer", __dir__)
15
+ build_dir = File.expand_path("tmp/selective_tracer", __dir__)
16
+ FileUtils.mkdir_p(build_dir)
17
+ Dir.chdir(build_dir) do
18
+ # extconf must not inherit this process's bundle; it only needs mkmf.
19
+ Bundler.with_unbundled_env do
20
+ sh RbConfig.ruby, File.join(ext_dir, "extconf.rb")
21
+ sh "make"
22
+ end
23
+ built = Dir["selective_tracer.{#{RbConfig::CONFIG["DLEXT"]},bundle,so}"].first
24
+ FileUtils.cp(built, File.expand_path("lib", __dir__)) if built
25
+ end
26
+ end
27
+
8
28
  require "standard/rake"
9
29
 
10
30
  task default: %i[spec standard]
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The tracer is optional. A customer without a C toolchain (or on an
4
+ # unsupported Ruby) must still be able to install selective-ruby-core and run
5
+ # their suite exactly as before, so any failed check writes a no-op Makefile
6
+ # instead of failing `gem install`. That includes compiling the tracer itself
7
+ # here first: an error in `make` would fail the install, so a Ruby whose
8
+ # headers the source doesn't build against must be caught before it. At
9
+ # runtime the missing library simply means "this runner can't record a test
10
+ # map".
11
+
12
+ require "mkmf"
13
+
14
+ def write_noop_makefile(reason)
15
+ warn "selective_tracer: skipping native build (#{reason}); test map recording will be unavailable"
16
+ File.write("Makefile", <<~MAKE)
17
+ all:
18
+ \t@true
19
+ install:
20
+ \t@true
21
+ clean:
22
+ \t@true
23
+ MAKE
24
+ end
25
+
26
+ begin
27
+ if RUBY_ENGINE != "ruby"
28
+ write_noop_makefile("#{RUBY_ENGINE} is not supported")
29
+ elsif !have_header("ruby/debug.h") || !have_func("rb_profile_frames", "ruby/debug.h")
30
+ write_noop_makefile("required VM hook APIs are missing")
31
+ else
32
+ $CFLAGS << " -O2 -std=c99 -Wall -Wno-unused-parameter" # standard:disable Style/GlobalVars -- mkmf's interface
33
+ if try_compile(File.read(File.expand_path("selective_tracer.c", __dir__)))
34
+ create_makefile("selective_tracer")
35
+ else
36
+ write_noop_makefile("the tracer does not compile on Ruby #{RUBY_VERSION}; see mkmf.log")
37
+ end
38
+ end
39
+ rescue => e
40
+ write_noop_makefile(e.message)
41
+ end
@@ -0,0 +1,336 @@
1
+ // selective_tracer — records which source files a single test executes.
2
+ //
3
+ // A test map is built from one question asked of every test: "which files
4
+ // did you run?". This extension answers it with two raw VM event hooks that
5
+ // never call back into Ruby while the test runs:
6
+ //
7
+ // * RUBY_EVENT_LINE | CALL | B_CALL, registered globally so work done on
8
+ // *any* thread is
9
+ // attributed to the running test. Capybara drives a Puma server thread;
10
+ // a hook scoped to the test's own thread (what rotoscope does) would map
11
+ // every system test to nothing and let selection skip them wrongly.
12
+ // Selective runs one test at a time per process, so "everything that ran
13
+ // between start and stop" is the right attribution. Stray background
14
+ // threads can only add files, which makes selection more conservative,
15
+ // never less. CALL and B_CALL are not redundant with LINE: an endless
16
+ // method (`def total = price * qty`) and a one-line block executed from
17
+ // another file emit no line event of their own, so a line-only hook never
18
+ // ties a test to the file they live in.
19
+ //
20
+ // * RUBY_INTERNAL_EVENT_NEWOBJ (optional), which records the class of every
21
+ // plain object allocated. A class whose file only ran at boot — an
22
+ // ActiveRecord model with no methods of its own — executes no lines
23
+ // during a test, so line events alone would never tie the test to it.
24
+ // The classes are resolved to source files in Ruby after the test stops.
25
+ //
26
+ // Why not Ruby's Coverage module: it is process-global, so it cannot run next
27
+ // to SimpleCov, and while it is enabled Bootsnap cannot use its compile cache.
28
+ // Why not TracePoint from Ruby: every event re-enters the interpreter. The
29
+ // design follows what Datadog learned building datadog-ci-rb (line hook with
30
+ // pointer caching, allocation tracking for code-less classes), written for
31
+ // Selective's needs: several project roots, several ignored prefixes, and no
32
+ // Ruby API use inside the allocation hook.
33
+
34
+ #include <ruby.h>
35
+ #include <ruby/debug.h>
36
+ #include <ruby/st.h>
37
+
38
+ #include <stdbool.h>
39
+ #include <string.h>
40
+
41
+ #define PATH_CACHE_SIZE 1024 // power of two
42
+ #define KLASS_CACHE_SIZE 4096 // power of two
43
+ #define MAX_PREFIXES 32
44
+ #define EXEC_EVENTS (RUBY_EVENT_LINE | RUBY_EVENT_CALL | RUBY_EVENT_B_CALL)
45
+
46
+ struct prefix_list {
47
+ int count;
48
+ char *values[MAX_PREFIXES];
49
+ long lengths[MAX_PREFIXES];
50
+ };
51
+
52
+ struct tracer {
53
+ // { path(String) => true } for the test being recorded.
54
+ VALUE files;
55
+
56
+ // Absolute paths are kept only under one of `roots` and outside every
57
+ // `ignored` prefix (bundled gems, Selective's own gems, tmp dirs).
58
+ struct prefix_list roots;
59
+ struct prefix_list ignored;
60
+
61
+ // Consecutive line events almost always come from the same file, and the
62
+ // path pointer of an iseq is stable, so a pointer comparison settles most
63
+ // events. The strings are marked so a freed path's address can't be reused
64
+ // for a different file and wrongly treated as "already seen".
65
+ VALUE last_path;
66
+ VALUE path_cache[PATH_CACHE_SIZE];
67
+
68
+ bool allocations;
69
+ bool running;
70
+
71
+ // Classes allocated during the test. Keys are class VALUEs, pinned by mark.
72
+ st_table *klasses;
73
+ VALUE last_klass;
74
+ VALUE klass_cache[KLASS_CACHE_SIZE];
75
+ };
76
+
77
+ static int mark_klass_i(st_data_t key, st_data_t _value, st_data_t _arg) {
78
+ rb_gc_mark((VALUE)key);
79
+ return ST_CONTINUE;
80
+ }
81
+
82
+ static void tracer_mark(void *ptr) {
83
+ struct tracer *t = ptr;
84
+ rb_gc_mark(t->files);
85
+ rb_gc_mark(t->last_path);
86
+ for (int i = 0; i < PATH_CACHE_SIZE; i++) rb_gc_mark(t->path_cache[i]);
87
+ rb_gc_mark(t->last_klass);
88
+ for (int i = 0; i < KLASS_CACHE_SIZE; i++) rb_gc_mark(t->klass_cache[i]);
89
+ if (t->klasses) st_foreach(t->klasses, mark_klass_i, 0);
90
+ }
91
+
92
+ static void free_prefixes(struct prefix_list *list) {
93
+ for (int i = 0; i < list->count; i++) xfree(list->values[i]);
94
+ list->count = 0;
95
+ }
96
+
97
+ static void tracer_free(void *ptr) {
98
+ struct tracer *t = ptr;
99
+ // No hook cleanup needed: the VM marks every registered hook's data, so a
100
+ // tracer that is still running is reachable and can't be freed.
101
+ free_prefixes(&t->roots);
102
+ free_prefixes(&t->ignored);
103
+ if (t->klasses) st_free_table(t->klasses);
104
+ xfree(t);
105
+ }
106
+
107
+ static size_t tracer_memsize(const void *ptr) {
108
+ return sizeof(struct tracer);
109
+ }
110
+
111
+ static const rb_data_type_t tracer_type = {
112
+ .wrap_struct_name = "selective_tracer",
113
+ .function = {.dmark = tracer_mark, .dfree = tracer_free, .dsize = tracer_memsize},
114
+ .flags = RUBY_TYPED_FREE_IMMEDIATELY};
115
+
116
+ static void reset_caches(struct tracer *t) {
117
+ t->last_path = Qnil;
118
+ for (int i = 0; i < PATH_CACHE_SIZE; i++) t->path_cache[i] = Qnil;
119
+ t->last_klass = Qnil;
120
+ for (int i = 0; i < KLASS_CACHE_SIZE; i++) t->klass_cache[i] = Qnil;
121
+ }
122
+
123
+ static VALUE tracer_alloc(VALUE klass) {
124
+ struct tracer *t;
125
+ VALUE self = TypedData_Make_Struct(klass, struct tracer, &tracer_type, t);
126
+ t->files = Qnil;
127
+ t->roots.count = 0;
128
+ t->ignored.count = 0;
129
+ t->allocations = false;
130
+ t->running = false;
131
+ t->klasses = NULL;
132
+ reset_caches(t);
133
+ t->files = rb_hash_new();
134
+ t->klasses = st_init_numtable();
135
+ return self;
136
+ }
137
+
138
+ static struct tracer *get_tracer(VALUE self) {
139
+ struct tracer *t;
140
+ TypedData_Get_Struct(self, struct tracer, &tracer_type, t);
141
+ return t;
142
+ }
143
+
144
+ static bool has_dir_prefix(const char *path, long path_len, const char *prefix, long prefix_len) {
145
+ if (prefix_len > path_len || memcmp(prefix, path, prefix_len) != 0) return false;
146
+ // "/app" must not match "/application"; a prefix given with a trailing
147
+ // slash already carries the boundary.
148
+ return prefix_len == path_len || prefix[prefix_len - 1] == '/' || path[prefix_len] == '/';
149
+ }
150
+
151
+ static bool path_included(struct tracer *t, const char *path, long len) {
152
+ if (len == 0) return false;
153
+
154
+ if (path[0] != '/') {
155
+ // Relative paths ("./spec/x_spec.rb" from `load`) are resolved and
156
+ // re-checked in Ruby. Pseudo-paths like "(eval at ...)" and
157
+ // "<internal:kernel>" are never project files.
158
+ return path[0] != '(' && path[0] != '<';
159
+ }
160
+
161
+ bool in_root = false;
162
+ for (int i = 0; i < t->roots.count; i++) {
163
+ if (has_dir_prefix(path, len, t->roots.values[i], t->roots.lengths[i])) {
164
+ in_root = true;
165
+ break;
166
+ }
167
+ }
168
+ if (!in_root) return false;
169
+
170
+ for (int i = 0; i < t->ignored.count; i++) {
171
+ if (has_dir_prefix(path, len, t->ignored.values[i], t->ignored.lengths[i])) return false;
172
+ }
173
+ return true;
174
+ }
175
+
176
+ static inline size_t cache_slot(uintptr_t ptr, size_t size) {
177
+ return ((ptr >> 4) ^ (ptr >> 12)) & (size - 1);
178
+ }
179
+
180
+ static void on_exec(rb_event_flag_t event, VALUE data, VALUE self, ID id, VALUE klass) {
181
+ struct tracer *t = RTYPEDDATA_DATA(data);
182
+
183
+ const char *current = rb_sourcefile();
184
+ if (current == NULL) return;
185
+
186
+ if (t->last_path != Qnil && RSTRING_PTR(t->last_path) == current) return;
187
+
188
+ size_t slot = cache_slot((uintptr_t)current, PATH_CACHE_SIZE);
189
+ VALUE cached = t->path_cache[slot];
190
+ if (cached != Qnil && RSTRING_PTR(cached) == current) {
191
+ t->last_path = cached;
192
+ return;
193
+ }
194
+
195
+ // First time this test sees this file: fetch the frame's path String so
196
+ // the pointer we cache is owned by a marked object.
197
+ VALUE frame;
198
+ if (rb_profile_frames(0, 1, &frame, NULL) != 1) return;
199
+
200
+ VALUE path = rb_profile_frame_path(frame);
201
+ if (NIL_P(path) || !RB_TYPE_P(path, T_STRING)) return;
202
+
203
+ t->last_path = path;
204
+ t->path_cache[slot] = path;
205
+
206
+ if (path_included(t, RSTRING_PTR(path), RSTRING_LEN(path))) {
207
+ rb_hash_aset(t->files, path, Qtrue);
208
+ }
209
+ }
210
+
211
+ // Runs inside object allocation: no Ruby method calls and no Ruby object
212
+ // allocation are allowed here. rb_obj_class, rb_mod_name and st_* do neither.
213
+ static void on_newobj(VALUE data, rb_trace_arg_t *targ) {
214
+ VALUE obj = rb_tracearg_object(targ);
215
+ enum ruby_value_type type = rb_type(obj);
216
+ if (type != RUBY_T_OBJECT && type != RUBY_T_STRUCT) return;
217
+
218
+ VALUE klass = rb_obj_class(obj);
219
+ if (klass == 0 || NIL_P(klass)) return;
220
+
221
+ struct tracer *t = RTYPEDDATA_DATA(data);
222
+ if (t->last_klass == klass) return;
223
+
224
+ size_t slot = cache_slot((uintptr_t)klass, KLASS_CACHE_SIZE);
225
+ if (t->klass_cache[slot] == klass) {
226
+ t->last_klass = klass;
227
+ return;
228
+ }
229
+
230
+ t->last_klass = klass;
231
+ t->klass_cache[slot] = klass;
232
+
233
+ if (st_is_member(t->klasses, (st_data_t)klass)) return;
234
+ if (NIL_P(rb_mod_name(klass))) return; // anonymous class: no file to resolve
235
+
236
+ st_insert(t->klasses, (st_data_t)klass, 1);
237
+ }
238
+
239
+ static void load_prefixes(struct prefix_list *list, VALUE ary, const char *name) {
240
+ if (NIL_P(ary)) return;
241
+ Check_Type(ary, T_ARRAY);
242
+ long n = RARRAY_LEN(ary);
243
+ if (n > MAX_PREFIXES) rb_raise(rb_eArgError, "at most %d %s are supported", MAX_PREFIXES, name);
244
+
245
+ for (long i = 0; i < n; i++) {
246
+ VALUE s = rb_ary_entry(ary, i);
247
+ Check_Type(s, T_STRING);
248
+ long len = RSTRING_LEN(s);
249
+ if (len == 0) continue;
250
+ char *copy = ALLOC_N(char, len + 1);
251
+ memcpy(copy, RSTRING_PTR(s), len);
252
+ copy[len] = '\0';
253
+ list->values[list->count] = copy;
254
+ list->lengths[list->count] = len;
255
+ list->count++;
256
+ }
257
+ }
258
+
259
+ // NativeTracer.new(roots: [...], ignored: [...], allocations: true/false)
260
+ static VALUE tracer_initialize(int argc, VALUE *argv, VALUE self) {
261
+ VALUE opts;
262
+ rb_scan_args(argc, argv, "1", &opts);
263
+ Check_Type(opts, T_HASH);
264
+
265
+ struct tracer *t = get_tracer(self);
266
+ free_prefixes(&t->roots);
267
+ free_prefixes(&t->ignored);
268
+
269
+ load_prefixes(&t->roots, rb_hash_aref(opts, ID2SYM(rb_intern("roots"))), "roots");
270
+ load_prefixes(&t->ignored, rb_hash_aref(opts, ID2SYM(rb_intern("ignored"))), "ignored prefixes");
271
+ if (t->roots.count == 0) rb_raise(rb_eArgError, "roots must contain at least one path");
272
+
273
+ t->allocations = RTEST(rb_hash_aref(opts, ID2SYM(rb_intern("allocations"))));
274
+ return self;
275
+ }
276
+
277
+ static VALUE tracer_start(VALUE self) {
278
+ struct tracer *t = get_tracer(self);
279
+ if (t->running) rb_raise(rb_eRuntimeError, "tracer is already running");
280
+
281
+ rb_hash_clear(t->files);
282
+ st_clear(t->klasses);
283
+ reset_caches(t);
284
+
285
+ rb_add_event_hook2((rb_event_hook_func_t)on_exec, EXEC_EVENTS, self, RUBY_EVENT_HOOK_FLAG_SAFE);
286
+ if (t->allocations) {
287
+ rb_add_event_hook2((rb_event_hook_func_t)on_newobj, RUBY_INTERNAL_EVENT_NEWOBJ, self,
288
+ RUBY_EVENT_HOOK_FLAG_SAFE | RUBY_EVENT_HOOK_FLAG_RAW_ARG);
289
+ }
290
+ t->running = true;
291
+ return self;
292
+ }
293
+
294
+ static int collect_klass_i(st_data_t key, st_data_t _value, st_data_t ary) {
295
+ rb_ary_push((VALUE)ary, (VALUE)key);
296
+ return ST_CONTINUE;
297
+ }
298
+
299
+ // Returns [files, classes]: the Hash of executed paths and the Array of named
300
+ // classes allocated during the test. Both are fresh objects owned by Ruby.
301
+ static VALUE tracer_stop(VALUE self) {
302
+ struct tracer *t = get_tracer(self);
303
+ if (!t->running) rb_raise(rb_eRuntimeError, "tracer is not running");
304
+
305
+ rb_remove_event_hook_with_data((rb_event_hook_func_t)on_exec, self);
306
+ if (t->allocations) rb_remove_event_hook_with_data((rb_event_hook_func_t)on_newobj, self);
307
+ t->running = false;
308
+
309
+ VALUE files = t->files;
310
+ t->files = rb_hash_new();
311
+
312
+ VALUE klasses = rb_ary_new_capa((long)t->klasses->num_entries);
313
+ st_foreach(t->klasses, collect_klass_i, (st_data_t)klasses);
314
+ st_clear(t->klasses);
315
+ reset_caches(t);
316
+
317
+ return rb_assoc_new(files, klasses);
318
+ }
319
+
320
+ static VALUE tracer_running_p(VALUE self) {
321
+ return get_tracer(self)->running ? Qtrue : Qfalse;
322
+ }
323
+
324
+ void Init_selective_tracer(void) {
325
+ VALUE mSelective = rb_define_module("Selective");
326
+ VALUE mRuby = rb_define_module_under(mSelective, "Ruby");
327
+ VALUE mCore = rb_define_module_under(mRuby, "Core");
328
+ VALUE mTestMap = rb_define_module_under(mCore, "TestMap");
329
+ VALUE cTracer = rb_define_class_under(mTestMap, "NativeTracer", rb_cObject);
330
+
331
+ rb_define_alloc_func(cTracer, tracer_alloc);
332
+ rb_define_method(cTracer, "initialize", tracer_initialize, -1);
333
+ rb_define_method(cTracer, "start", tracer_start, 0);
334
+ rb_define_method(cTracer, "stop", tracer_stop, 0);
335
+ rb_define_method(cTracer, "running?", tracer_running_p, 0);
336
+ }
@@ -24,10 +24,9 @@ module Selective
24
24
 
25
25
  def initialize(runner_class, runner_args, debug: false, log: false)
26
26
  @debug = debug
27
+ @log = log
27
28
  @runner = runner_class.new(runner_args, method(:test_case_callback))
28
29
  @retries = 0
29
- @runner_id = safe_filename(get_runner_id)
30
- @logger = init_logger(log)
31
30
  end
32
31
 
33
32
  def start(reconnect: false)
@@ -67,7 +66,19 @@ module Selective
67
66
 
68
67
  private
69
68
 
70
- 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
71
82
 
72
83
  def get_runner_id
73
84
  runner_id = build_env["runner_id"]
@@ -90,7 +101,7 @@ module Selective
90
101
  message = pipe.read
91
102
  response = JSON.parse(message, symbolize_names: true)
92
103
 
93
- @logger.info("Received Command: #{response}")
104
+ logger.info("Received Command: #{response}")
94
105
  break if handle_command(response) == :break
95
106
  end
96
107
  end
@@ -140,6 +151,13 @@ module Selective
140
151
  "framework" => runner.framework,
141
152
  "framework_version" => runner.framework_version,
142
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
143
161
  }.merge(metadata: metadata.to_json)
144
162
  end
145
163
  end
@@ -271,8 +289,11 @@ module Selective
271
289
 
272
290
  def handle_test_manifest(_data)
273
291
  self.class.restore_reporting!
274
- @logger.info("Sending Response: test_manifest")
292
+ logger.info("Sending Response: test_manifest")
275
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
276
297
  num_commits = build_env["num_commits"] || 1000
277
298
  if (diff = get_diff(num_commits))
278
299
  data[:modified_test_files] = modified_test_files(diff)
@@ -281,6 +302,22 @@ module Selective
281
302
  write({type: "test_manifest", data: data})
282
303
  end
283
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
+
284
321
  def handle_run_test_cases(data)
285
322
  runner.run_test_cases(data[:test_case_ids])
286
323
  end
@@ -307,6 +344,8 @@ module Selective
307
344
  runner.finish unless exit_status.is_a?(Integer)
308
345
  end
309
346
 
347
+ upload_test_map
348
+
310
349
  kill_transport
311
350
  pipe.delete_pipes
312
351
  exit(exit_status || runner.exit_status)
@@ -315,12 +354,40 @@ module Selective
315
354
  :break
316
355
  end
317
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
+
318
385
  def correlated_files(diff, num_commits)
319
386
  Selective::Ruby::Core::FileCorrelator.new(diff, num_commits, build_env["target_branch"]).correlate
320
387
  end
321
388
 
322
389
  def test_case_callback(test_case)
323
- @logger.info("Sending Response: test_case_result: #{test_case[:id]}")
390
+ logger.info("Sending Response: test_case_result: #{test_case[:id]}")
324
391
  write({type: "test_case_result", data: test_case})
325
392
  end
326
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.9"
6
+ VERSION = "0.2.10"
7
7
  end
8
8
  end
9
9
  end
File without changes
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/selective/ruby/core/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "selective-ruby-core"
7
+ spec.version = Selective::Ruby::Core::VERSION
8
+ spec.authors = ["Benjamin Wood", "Nate Vick"]
9
+ spec.email = ["ben@hint.io", "nate@hint.io"]
10
+ spec.license = "MIT"
11
+
12
+ # Currently supported release platforms are:
13
+ # Linux: x86_64-linux, aarch64-linux
14
+ # MacOS: x86_64-darwin, arm64-darwin
15
+ if (platform = ENV["RELEASE_PLATFORM"])
16
+ spec.platform = platform
17
+ end
18
+
19
+ spec.summary = "Selective Ruby Client Core"
20
+ spec.description = "Selective is an intelligent test runner for your current CI provider. Get real-time test results, intelligent ordering based on code changes, shorter run times, automatic flake detection, the ability to re-enqueue failed tests, and more."
21
+ spec.homepage = "https://www.selective.ci"
22
+ spec.required_ruby_version = ">= 2.6.0"
23
+
24
+ spec.metadata["homepage_uri"] = spec.homepage
25
+ spec.metadata["source_code_uri"] = "http://github.com/selectiveci/selective-ruby-core"
26
+ spec.metadata["changelog_uri"] = "https://github.com/selectiveci/selective-ruby-core/blob/main/CHANGELOG.md"
27
+
28
+ # Specify which files should be added to the gem when it is released.
29
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
30
+ spec.files = Dir.chdir(__dir__) do
31
+ `git ls-files -z`.split("\x0").reject do |f|
32
+ (File.expand_path(f) == __FILE__) || f.start_with?(*%w[bin/ test/ spec/ features/ log/ .git .circleci appveyor .rspec .ruby-version .standard.yml README.md CHANGELOG.md CODE_OF_CONDUCT.md Gemfile])
33
+ end
34
+ end
35
+
36
+ # Add the transport binary
37
+ unless ENV["RELEASE_PLATFORM"].nil?
38
+ spec.files << "lib/bin/transport"
39
+ end
40
+
41
+ # The optional test map tracer. extconf.rb writes a no-op Makefile when it
42
+ # can't build (no compiler, unsupported Ruby), so installation never fails
43
+ # because of it; the runner then reports it can't record.
44
+ spec.extensions = ["ext/selective_tracer/extconf.rb"]
45
+
46
+ spec.bindir = "exe"
47
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
48
+ spec.require_paths = ["lib"]
49
+
50
+ # Uncomment to register a new dependency of your gem
51
+ spec.add_dependency("logger", "~> 1.6")
52
+ spec.add_dependency("zeitwerk", "~> 2.6.12")
53
+ end
metadata CHANGED
@@ -1,15 +1,15 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: selective-ruby-core
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.9
4
+ version: 0.2.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - Benjamin Wood
8
8
  - Nate Vick
9
- autorequire:
9
+ autorequire:
10
10
  bindir: exe
11
11
  cert_chain: []
12
- date: 2026-05-03 00:00:00.000000000 Z
12
+ date: 2026-09-29 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: logger
@@ -48,12 +48,15 @@ email:
48
48
  - nate@hint.io
49
49
  executables:
50
50
  - selective
51
- extensions: []
51
+ extensions:
52
+ - ext/selective_tracer/extconf.rb
52
53
  extra_rdoc_files: []
53
54
  files:
54
55
  - LICENSE
55
56
  - Rakefile
56
57
  - exe/selective
58
+ - ext/selective_tracer/extconf.rb
59
+ - ext/selective_tracer/selective_tracer.c
57
60
  - lib/bin/build_env.sh
58
61
  - lib/bin/file_correlation_collector.sh
59
62
  - lib/selective-ruby-core.rb
@@ -61,7 +64,13 @@ files:
61
64
  - lib/selective/ruby/core/file_correlator.rb
62
65
  - lib/selective/ruby/core/helper.rb
63
66
  - lib/selective/ruby/core/named_pipe.rb
67
+ - lib/selective/ruby/core/test_map.rb
68
+ - lib/selective/ruby/core/test_map/recorder.rb
69
+ - lib/selective/ruby/core/test_map/tracer.rb
70
+ - lib/selective/ruby/core/test_map/tree.rb
71
+ - lib/selective/ruby/core/test_map/uploader.rb
64
72
  - lib/selective/ruby/core/version.rb
73
+ - selective-ruby-core.gemspec
65
74
  homepage: https://www.selective.ci
66
75
  licenses:
67
76
  - MIT
@@ -69,7 +78,7 @@ metadata:
69
78
  homepage_uri: https://www.selective.ci
70
79
  source_code_uri: http://github.com/selectiveci/selective-ruby-core
71
80
  changelog_uri: https://github.com/selectiveci/selective-ruby-core/blob/main/CHANGELOG.md
72
- post_install_message:
81
+ post_install_message:
73
82
  rdoc_options: []
74
83
  require_paths:
75
84
  - lib
@@ -85,7 +94,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
85
94
  version: '0'
86
95
  requirements: []
87
96
  rubygems_version: 3.5.22
88
- signing_key:
97
+ signing_key:
89
98
  specification_version: 4
90
99
  summary: Selective Ruby Client Core
91
100
  test_files: []