selective-ruby-core 0.2.9-arm64-darwin → 0.2.11-arm64-darwin

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: 9fb9166f80a79c7b340092eb979d4c974a6b50f11e79f07cedf9b6213a1e2f45
4
- data.tar.gz: e767793a17ded8bb9fb546afb073f62765bea219100f8628d61f83430bd8ab5a
3
+ metadata.gz: ab378b202901a57bb10c8fad646475a828e6e5f30b5d3b3b2fee7816acdd486c
4
+ data.tar.gz: 7cbb872c5beb7ae8de76b2f725c51cdec67b2ef5c8db62c28b8ec342431b5231
5
5
  SHA512:
6
- metadata.gz: 8b4ff326bfaf3567d2dd0aba7bc0b56f11b064fe389cb1776bc197011fe4cbd680ebd4a398e3ef4f56d71fde44cfe24b2ac7dc5d3320218fbe8a5c673776c1df
7
- data.tar.gz: e746d785d0277a6dd5ebb57cf7156a0c684975cf4f3bbdb71e78c151bee85bd615f9094f8a8adbeb63350443e21d3fc0212a5a57a2d6ef6972a2d54f506506f9
6
+ metadata.gz: 1187ebdc776b21dd80bb12f42a9284a836d97097944d98e4d97a523f6fe9044bf18783f9495f8a948d56597fd2133abd846b0be099e3ca76b235f81ed000f31d
7
+ data.tar.gz: fb41935ee99f139a541aa7d0c68a9cf525fadd6e3290a95d4c984d7c3fecc60437d48fcb8198575ebe329b57ff9e5a8ce536763b2e43f73043c8e3b0312b705f
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,21 +24,24 @@ 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
 
32
+ # A lost connection is retried by looping here, not by calling start
33
+ # again from inside the rescue clause. Everything a reconnected runner
34
+ # does -- the rest of the run, the close, the framework's after_run
35
+ # hooks -- would otherwise run inside that clause, where `$!` is still
36
+ # the ConnectionLostError. SimpleCov's after_run hook reads that as the
37
+ # run having crashed and exits 1, so every CI shard whose connection
38
+ # dropped once failed with no failing test.
33
39
  def start(reconnect: false)
34
- @pipe = NamedPipe.new("/tmp/#{runner_id}_2", "/tmp/#{runner_id}_1")
35
- @transport_pid = spawn_transport_process(reconnect: reconnect)
40
+ loop do
41
+ break if connect_and_run(reconnect: reconnect)
36
42
 
37
- handle_termination_signals(transport_pid)
38
- wait_for_connectivity
39
- run_main_loop
40
- rescue ConnectionLostError
41
- retry!
43
+ reconnect = true
44
+ end
42
45
  rescue => e
43
46
  with_error_handling { raise e }
44
47
  end
@@ -67,7 +70,19 @@ module Selective
67
70
 
68
71
  private
69
72
 
70
- attr_reader :runner, :pipe, :transport_pid, :retries, :logger, :runner_id, :diff
73
+ attr_reader :runner, :pipe, :transport_pid, :retries, :diff
74
+
75
+ # Resolved on first use rather than in the constructor: `selective
76
+ # exec` (the plain framework run behind the manifest dry-run) never
77
+ # connects, so it must not require the CI environment that naming a
78
+ # runner validates.
79
+ def runner_id
80
+ @runner_id ||= safe_filename(get_runner_id)
81
+ end
82
+
83
+ def logger
84
+ @logger ||= init_logger(@log)
85
+ end
71
86
 
72
87
  def get_runner_id
73
88
  runner_id = build_env["runner_id"]
@@ -90,12 +105,27 @@ module Selective
90
105
  message = pipe.read
91
106
  response = JSON.parse(message, symbolize_names: true)
92
107
 
93
- @logger.info("Received Command: #{response}")
108
+ logger.info("Received Command: #{response}")
94
109
  break if handle_command(response) == :break
95
110
  end
96
111
  end
97
112
 
98
- def retry!
113
+ # One connection: true when the run finished on it, false when it was
114
+ # lost and the caller should reconnect.
115
+ def connect_and_run(reconnect:)
116
+ @pipe = NamedPipe.new("/tmp/#{runner_id}_2", "/tmp/#{runner_id}_1")
117
+ @transport_pid = spawn_transport_process(reconnect: reconnect)
118
+
119
+ handle_termination_signals(transport_pid)
120
+ wait_for_connectivity
121
+ run_main_loop
122
+ true
123
+ rescue ConnectionLostError
124
+ prepare_retry!
125
+ false
126
+ end
127
+
128
+ def prepare_retry!
99
129
  @retries += 1
100
130
 
101
131
  with_error_handling { raise "Too many retries" } if retries > 10
@@ -105,7 +135,6 @@ module Selective
105
135
  kill_transport
106
136
 
107
137
  pipe.reset!
108
- start(reconnect: true)
109
138
  end
110
139
 
111
140
  def write(data)
@@ -140,6 +169,13 @@ module Selective
140
169
  "framework" => runner.framework,
141
170
  "framework_version" => runner.framework_version,
142
171
  "framework_wrapper_version" => runner.wrapper_version,
172
+ "runtime_version" => RUBY_VERSION,
173
+ # "record", "select" or "0": whether this runner can record a
174
+ # test map, only run selected subsets, or neither.
175
+ "test_map" => TestMap.capability,
176
+ # Forces this run to record (e.g. a nightly full run), however
177
+ # fresh the current map is.
178
+ "test_map_record" => ENV["SELECTIVE_TEST_MAP_RECORD"].to_s
143
179
  }.merge(metadata: metadata.to_json)
144
180
  end
145
181
  end
@@ -271,8 +307,11 @@ module Selective
271
307
 
272
308
  def handle_test_manifest(_data)
273
309
  self.class.restore_reporting!
274
- @logger.info("Sending Response: test_manifest")
310
+ logger.info("Sending Response: test_manifest")
275
311
  data = {test_cases: runner.manifest["examples"] || runner.manifest["test_cases"]}
312
+ if TestMap.tree_requested? && (tree = test_map_tree)
313
+ data[:test_map_tree] = tree
314
+ end
276
315
  num_commits = build_env["num_commits"] || 1000
277
316
  if (diff = get_diff(num_commits))
278
317
  data[:modified_test_files] = modified_test_files(diff)
@@ -281,6 +320,22 @@ module Selective
281
320
  write({type: "test_manifest", data: data})
282
321
  end
283
322
 
323
+ # Sent by servers that support test maps, before any tests are
324
+ # assigned. `tree: true` asks for a file-hash snapshot with the
325
+ # manifest; `record: true` turns on tracing for every test this runner
326
+ # executes.
327
+ def handle_configure_test_map(data)
328
+ recorder = TestMap.configure(
329
+ record: data[:record] == true,
330
+ tree: data[:tree] == true,
331
+ allocations: data[:allocations] != false
332
+ )
333
+ logger.info("Test map configured: record=#{!recorder.nil?} tree=#{TestMap.tree_requested?}")
334
+ if data[:record] == true && recorder.nil?
335
+ print_warning("Selective asked this runner to record a test map, but the native tracer isn't available. Tests will run normally.")
336
+ end
337
+ end
338
+
284
339
  def handle_run_test_cases(data)
285
340
  runner.run_test_cases(data[:test_case_ids])
286
341
  end
@@ -307,6 +362,8 @@ module Selective
307
362
  runner.finish unless exit_status.is_a?(Integer)
308
363
  end
309
364
 
365
+ upload_test_map
366
+
310
367
  kill_transport
311
368
  pipe.delete_pipes
312
369
  exit(exit_status || runner.exit_status)
@@ -315,12 +372,40 @@ module Selective
315
372
  :break
316
373
  end
317
374
 
375
+ def test_map_tree
376
+ TestMap::Tree.snapshot
377
+ rescue => e
378
+ logger.warn("Could not snapshot the repository for test selection: #{e.message}")
379
+ nil
380
+ end
381
+
382
+ def upload_test_map
383
+ recorder = TestMap.recorder
384
+ return if recorder.nil? || recorder.empty?
385
+
386
+ uploader = TestMap::Uploader.new(
387
+ host: build_env["host"],
388
+ api_key: build_env["api_key"],
389
+ run_id: build_env["run_id"],
390
+ run_attempt: build_env["run_attempt"],
391
+ runner_id: runner_id,
392
+ logger: logger
393
+ )
394
+ if uploader.upload(recorder.to_h)
395
+ logger.info("Uploaded test map fragment: #{recorder.test_count} tests")
396
+ else
397
+ 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.")
398
+ end
399
+ rescue => e
400
+ logger.warn("Test map upload failed: #{e.class}: #{e.message}")
401
+ end
402
+
318
403
  def correlated_files(diff, num_commits)
319
404
  Selective::Ruby::Core::FileCorrelator.new(diff, num_commits, build_env["target_branch"]).correlate
320
405
  end
321
406
 
322
407
  def test_case_callback(test_case)
323
- @logger.info("Sending Response: test_case_result: #{test_case[:id]}")
408
+ logger.info("Sending Response: test_case_result: #{test_case[:id]}")
324
409
  write({type: "test_case_result", data: test_case})
325
410
  end
326
411
 
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.11"
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.11
5
5
  platform: arm64-darwin
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/bin/transport
@@ -62,7 +65,13 @@ files:
62
65
  - lib/selective/ruby/core/file_correlator.rb
63
66
  - lib/selective/ruby/core/helper.rb
64
67
  - lib/selective/ruby/core/named_pipe.rb
68
+ - lib/selective/ruby/core/test_map.rb
69
+ - lib/selective/ruby/core/test_map/recorder.rb
70
+ - lib/selective/ruby/core/test_map/tracer.rb
71
+ - lib/selective/ruby/core/test_map/tree.rb
72
+ - lib/selective/ruby/core/test_map/uploader.rb
65
73
  - lib/selective/ruby/core/version.rb
74
+ - selective-ruby-core.gemspec
66
75
  homepage: https://www.selective.ci
67
76
  licenses:
68
77
  - MIT
@@ -70,7 +79,7 @@ metadata:
70
79
  homepage_uri: https://www.selective.ci
71
80
  source_code_uri: http://github.com/selectiveci/selective-ruby-core
72
81
  changelog_uri: https://github.com/selectiveci/selective-ruby-core/blob/main/CHANGELOG.md
73
- post_install_message:
82
+ post_install_message:
74
83
  rdoc_options: []
75
84
  require_paths:
76
85
  - lib
@@ -86,7 +95,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
86
95
  version: '0'
87
96
  requirements: []
88
97
  rubygems_version: 3.5.22
89
- signing_key:
98
+ signing_key:
90
99
  specification_version: 4
91
100
  summary: Selective Ruby Client Core
92
101
  test_files: []