fast_cov 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +339 -0
- data/ext/fast_cov/extconf.rb +12 -0
- data/ext/fast_cov/fast_cov.c +615 -0
- data/ext/fast_cov/fast_cov.h +32 -0
- data/ext/fast_cov/fast_cov_utils.c +104 -0
- data/lib/fast_cov/benchmark/runner.rb +147 -0
- data/lib/fast_cov/benchmark/scenarios.rb +103 -0
- data/lib/fast_cov/compiler.rb +56 -0
- data/lib/fast_cov/configuration.rb +27 -0
- data/lib/fast_cov/constant_extractor.rb +53 -0
- data/lib/fast_cov/dev.rb +22 -0
- data/lib/fast_cov/trackers/abstract_tracker.rb +73 -0
- data/lib/fast_cov/trackers/coverage_tracker.rb +28 -0
- data/lib/fast_cov/trackers/factory_bot_tracker.rb +52 -0
- data/lib/fast_cov/trackers/file_tracker.rb +48 -0
- data/lib/fast_cov/version.rb +5 -0
- data/lib/fast_cov.rb +62 -0
- metadata +117 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#include "fast_cov.h"
|
|
2
|
+
#include <ruby.h>
|
|
3
|
+
#include <string.h>
|
|
4
|
+
|
|
5
|
+
// Check if path is within root directory.
|
|
6
|
+
// Handles trailing slashes on root and ensures we don't match
|
|
7
|
+
// /a/b/c against /a/b/cd (sibling directory with longer name).
|
|
8
|
+
bool fast_cov_is_within_root(const char *path, long path_len,
|
|
9
|
+
const char *root, long root_len) {
|
|
10
|
+
// Normalize: strip trailing slash from root for comparison
|
|
11
|
+
long effective_root_len = root_len;
|
|
12
|
+
if (effective_root_len > 0 && root[effective_root_len - 1] == '/') {
|
|
13
|
+
effective_root_len--;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Path must be at least as long as root
|
|
17
|
+
if (path_len < effective_root_len) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Check prefix match
|
|
22
|
+
if (strncmp(path, root, effective_root_len) != 0) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Path is exactly root (rare but valid)
|
|
27
|
+
if (path_len == effective_root_len) {
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Path must have '/' immediately after root prefix
|
|
32
|
+
// This prevents /a/b/c from matching /a/b/cd
|
|
33
|
+
return path[effective_root_len] == '/';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
bool fast_cov_is_path_included(const char *path, const char *root_path,
|
|
37
|
+
long root_path_len, const char *ignored_path,
|
|
38
|
+
long ignored_path_len) {
|
|
39
|
+
long path_len = (long)strlen(path);
|
|
40
|
+
|
|
41
|
+
if (!fast_cov_is_within_root(path, path_len, root_path, root_path_len)) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
if (ignored_path_len > 0 &&
|
|
45
|
+
fast_cov_is_within_root(path, path_len, ignored_path, ignored_path_len)) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
char *fast_cov_ruby_strndup(const char *str, size_t size) {
|
|
52
|
+
char *dup = xmalloc(size + 1);
|
|
53
|
+
memcpy(dup, str, size);
|
|
54
|
+
dup[size] = '\0';
|
|
55
|
+
return dup;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
VALUE fast_cov_rescue_nil(VALUE (*fn)(VALUE), VALUE arg) {
|
|
59
|
+
int exception_state;
|
|
60
|
+
VALUE result = rb_protect(fn, arg, &exception_state);
|
|
61
|
+
if (exception_state != 0) {
|
|
62
|
+
rb_set_errinfo(Qnil);
|
|
63
|
+
return Qnil;
|
|
64
|
+
}
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
VALUE fast_cov_get_const_source_location(VALUE const_name_str) {
|
|
69
|
+
return rb_funcall(rb_cObject, rb_intern("const_source_location"), 1,
|
|
70
|
+
const_name_str);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
VALUE fast_cov_safely_get_const_source_location(VALUE const_name_str) {
|
|
74
|
+
return fast_cov_rescue_nil(fast_cov_get_const_source_location,
|
|
75
|
+
const_name_str);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
VALUE fast_cov_resolve_const_to_file(VALUE const_name_str) {
|
|
79
|
+
// Check cache first
|
|
80
|
+
VALUE const_locations_hash =
|
|
81
|
+
rb_hash_lookup(fast_cov_cache_hash, ID2SYM(rb_intern("const_locations")));
|
|
82
|
+
VALUE cached = rb_hash_lookup(const_locations_hash, const_name_str);
|
|
83
|
+
if (cached != Qnil) {
|
|
84
|
+
return cached;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Cache miss - resolve via Object.const_source_location
|
|
88
|
+
VALUE source_location =
|
|
89
|
+
fast_cov_safely_get_const_source_location(const_name_str);
|
|
90
|
+
if (NIL_P(source_location) || !RB_TYPE_P(source_location, T_ARRAY) ||
|
|
91
|
+
RARRAY_LEN(source_location) == 0) {
|
|
92
|
+
return Qnil;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
VALUE filename = RARRAY_AREF(source_location, 0);
|
|
96
|
+
if (NIL_P(filename) || !RB_TYPE_P(filename, T_STRING)) {
|
|
97
|
+
return Qnil;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Cache the result
|
|
101
|
+
rb_hash_aset(const_locations_hash, const_name_str, filename);
|
|
102
|
+
|
|
103
|
+
return filename;
|
|
104
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module FastCov
|
|
7
|
+
module Benchmark
|
|
8
|
+
class Runner
|
|
9
|
+
DEFAULT_ITERATIONS = 1000
|
|
10
|
+
DEFAULT_SAMPLES = 5
|
|
11
|
+
WARMUP_ITERATIONS = 20
|
|
12
|
+
|
|
13
|
+
attr_reader :iterations, :samples, :baseline_path
|
|
14
|
+
|
|
15
|
+
def initialize(iterations: DEFAULT_ITERATIONS, samples: DEFAULT_SAMPLES, baseline_path: nil)
|
|
16
|
+
@iterations = iterations
|
|
17
|
+
@samples = samples
|
|
18
|
+
@baseline_path = baseline_path || default_baseline_path
|
|
19
|
+
@scenarios = []
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def scenario(name, &block)
|
|
23
|
+
@scenarios << { name: name, block: block }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def run(save_baseline: false)
|
|
27
|
+
baseline = load_baseline unless save_baseline
|
|
28
|
+
results = run_scenarios
|
|
29
|
+
|
|
30
|
+
print_results(results, baseline)
|
|
31
|
+
|
|
32
|
+
if save_baseline
|
|
33
|
+
save_baseline_to_disk(results)
|
|
34
|
+
elsif baseline
|
|
35
|
+
puts "Baseline: #{@baseline_path} (saved #{baseline["saved_at"]})"
|
|
36
|
+
else
|
|
37
|
+
puts "No baseline found. Run with --baseline to save one."
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def run_scenarios
|
|
44
|
+
results = {}
|
|
45
|
+
|
|
46
|
+
@scenarios.each do |scenario|
|
|
47
|
+
measurement = measure(scenario[:block])
|
|
48
|
+
results[scenario[:name]] = measurement
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
results
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def measure(block)
|
|
55
|
+
# Warmup: let JIT, caches, and memory settle
|
|
56
|
+
WARMUP_ITERATIONS.times { block.call }
|
|
57
|
+
|
|
58
|
+
# Collect multiple samples, take the median to filter outliers
|
|
59
|
+
elapsed_samples = Array.new(@samples) do
|
|
60
|
+
GC.start
|
|
61
|
+
GC.compact
|
|
62
|
+
|
|
63
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
64
|
+
@iterations.times { block.call }
|
|
65
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
median_elapsed = median(elapsed_samples)
|
|
69
|
+
|
|
70
|
+
{
|
|
71
|
+
"avg_ms" => (median_elapsed / @iterations) * 1000.0,
|
|
72
|
+
"ips" => @iterations / median_elapsed
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def median(values)
|
|
77
|
+
sorted = values.sort
|
|
78
|
+
mid = sorted.length / 2
|
|
79
|
+
if sorted.length.odd?
|
|
80
|
+
sorted[mid]
|
|
81
|
+
else
|
|
82
|
+
(sorted[mid - 1] + sorted[mid]) / 2.0
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def print_results(results, baseline)
|
|
87
|
+
has_baseline = baseline && baseline["results"]
|
|
88
|
+
|
|
89
|
+
puts "FastCov Benchmark Suite"
|
|
90
|
+
puts "Ruby #{RUBY_VERSION}, #{RUBY_PLATFORM}"
|
|
91
|
+
puts "#{@iterations} iterations x #{@samples} samples (median)"
|
|
92
|
+
puts "=" * 72
|
|
93
|
+
puts
|
|
94
|
+
|
|
95
|
+
header = format(" %-38s %10s %12s", "", "avg (ms)", "ips")
|
|
96
|
+
header += format(" %14s", "vs baseline") if has_baseline
|
|
97
|
+
puts header
|
|
98
|
+
puts "-" * header.length
|
|
99
|
+
|
|
100
|
+
results.each do |name, result|
|
|
101
|
+
line = format(" %-38s %10.3f %12.1f", name, result["avg_ms"], result["ips"])
|
|
102
|
+
|
|
103
|
+
if has_baseline && (base = baseline["results"][name])
|
|
104
|
+
base_avg = base["avg_ms"]
|
|
105
|
+
if base_avg > 0
|
|
106
|
+
delta_pct = ((result["avg_ms"] - base_avg) / base_avg) * 100.0
|
|
107
|
+
sign = delta_pct >= 0 ? "+" : ""
|
|
108
|
+
line += format(" %13s", "#{sign}#{"%.1f" % delta_pct}%")
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
puts line
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
puts
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def load_baseline
|
|
119
|
+
return nil unless File.exist?(@baseline_path)
|
|
120
|
+
|
|
121
|
+
JSON.parse(File.read(@baseline_path))
|
|
122
|
+
rescue JSON::ParserError
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def save_baseline_to_disk(results)
|
|
127
|
+
FileUtils.mkdir_p(File.dirname(@baseline_path))
|
|
128
|
+
|
|
129
|
+
payload = {
|
|
130
|
+
"saved_at" => Time.now.strftime("%Y-%m-%d %H:%M:%S"),
|
|
131
|
+
"ruby_version" => RUBY_VERSION,
|
|
132
|
+
"platform" => RUBY_PLATFORM,
|
|
133
|
+
"iterations" => @iterations,
|
|
134
|
+
"samples" => @samples,
|
|
135
|
+
"results" => results
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
File.write(@baseline_path, JSON.pretty_generate(payload))
|
|
139
|
+
puts "Baseline saved to #{@baseline_path}"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def default_baseline_path
|
|
143
|
+
File.expand_path("tmp/benchmark_baseline.json", Dir.pwd)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FastCov
|
|
4
|
+
module Benchmark
|
|
5
|
+
module Scenarios
|
|
6
|
+
def self.register(runner, fixtures_dir:)
|
|
7
|
+
root_calculator = File.join(fixtures_dir, "calculator")
|
|
8
|
+
root_app = File.join(fixtures_dir, "app")
|
|
9
|
+
root_all = fixtures_dir
|
|
10
|
+
ignored_path = File.join(fixtures_dir, "vendor")
|
|
11
|
+
|
|
12
|
+
calculator = Calculator.new
|
|
13
|
+
|
|
14
|
+
runner.scenario("Line coverage (small)") do
|
|
15
|
+
cov = FastCov::Coverage.new(root: root_calculator)
|
|
16
|
+
cov.start
|
|
17
|
+
calculator.add(1, 2)
|
|
18
|
+
calculator.subtract(3, 1)
|
|
19
|
+
cov.stop
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
runner.scenario("Line coverage (many files)") do
|
|
23
|
+
cov = FastCov::Coverage.new(root: root_all)
|
|
24
|
+
cov.start
|
|
25
|
+
calculator.add(1, 2)
|
|
26
|
+
calculator.subtract(3, 1)
|
|
27
|
+
calculator.multiply(2, 3)
|
|
28
|
+
calculator.divide(6, 2)
|
|
29
|
+
ConstantReader.new.operations
|
|
30
|
+
MyModel.new
|
|
31
|
+
User.new("test", "test@test.com")
|
|
32
|
+
DynamicModel.new.some_method
|
|
33
|
+
cov.stop
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
runner.scenario("Line coverage (single-threaded)") do
|
|
37
|
+
cov = FastCov::Coverage.new(root: root_calculator, threads: false)
|
|
38
|
+
cov.start
|
|
39
|
+
calculator.add(1, 2)
|
|
40
|
+
calculator.subtract(3, 1)
|
|
41
|
+
cov.stop
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
runner.scenario("Line coverage (with ignored_path)") do
|
|
45
|
+
cov = FastCov::Coverage.new(root: root_all, ignored_path: ignored_path)
|
|
46
|
+
cov.start
|
|
47
|
+
calculator.add(1, 2)
|
|
48
|
+
calculator.subtract(3, 1)
|
|
49
|
+
cov.stop
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
runner.scenario("Allocation tracing") do
|
|
53
|
+
cov = FastCov::Coverage.new(root: root_app, allocations: true)
|
|
54
|
+
cov.start
|
|
55
|
+
MyModel.new
|
|
56
|
+
User.new("test", "test@test.com")
|
|
57
|
+
DynamicModel.new
|
|
58
|
+
cov.stop
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
runner.scenario("Constant resolution (cold cache)") do
|
|
62
|
+
FastCov::Cache.clear
|
|
63
|
+
cov = FastCov::Coverage.new(root: root_calculator)
|
|
64
|
+
cov.start
|
|
65
|
+
ConstantReader.new.operations
|
|
66
|
+
calculator.add(1, 2)
|
|
67
|
+
cov.stop
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
runner.scenario("Constant resolution (warm cache)") do
|
|
71
|
+
warm = FastCov::Coverage.new(root: root_calculator)
|
|
72
|
+
warm.start
|
|
73
|
+
ConstantReader.new.operations
|
|
74
|
+
warm.stop
|
|
75
|
+
|
|
76
|
+
cov = FastCov::Coverage.new(root: root_calculator)
|
|
77
|
+
cov.start
|
|
78
|
+
ConstantReader.new.operations
|
|
79
|
+
calculator.add(1, 2)
|
|
80
|
+
cov.stop
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
runner.scenario("Rapid start/stop (100x)") do
|
|
84
|
+
cov = FastCov::Coverage.new(root: root_calculator)
|
|
85
|
+
100.times do
|
|
86
|
+
cov.start
|
|
87
|
+
calculator.add(1, 2)
|
|
88
|
+
cov.stop
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
runner.scenario("Multi-threaded coverage") do
|
|
93
|
+
cov = FastCov::Coverage.new(root: root_calculator)
|
|
94
|
+
cov.start
|
|
95
|
+
t = Thread.new { calculator.add(1, 2) }
|
|
96
|
+
calculator.multiply(2, 3)
|
|
97
|
+
t.join
|
|
98
|
+
cov.stop
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "rbconfig"
|
|
5
|
+
require "digest/md5"
|
|
6
|
+
|
|
7
|
+
module FastCov
|
|
8
|
+
module Compiler
|
|
9
|
+
EXT_DIR = File.expand_path("../../ext/fast_cov", __dir__)
|
|
10
|
+
LIB_DIR = File.expand_path("../fast_cov", __dir__)
|
|
11
|
+
|
|
12
|
+
def self.compile!
|
|
13
|
+
Dir.chdir(EXT_DIR) do
|
|
14
|
+
system(RbConfig.ruby, "extconf.rb") || raise("FastCov: extconf.rb failed")
|
|
15
|
+
system("make") || raise("FastCov: make failed")
|
|
16
|
+
system("make install sitearchdir=#{LIB_DIR} sitelibdir=#{LIB_DIR}") || raise("FastCov: make install failed")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
write_digest
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.stale?
|
|
23
|
+
return true unless extension_exists?
|
|
24
|
+
|
|
25
|
+
stored = read_digest
|
|
26
|
+
return true unless stored
|
|
27
|
+
|
|
28
|
+
stored != source_digest
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.extension_exists?
|
|
32
|
+
ext_name = "fast_cov.#{RUBY_VERSION}_#{RUBY_PLATFORM}"
|
|
33
|
+
Dir.glob(File.join(LIB_DIR, "#{ext_name}.{bundle,so}")).any?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.source_digest
|
|
37
|
+
files = Dir.glob(File.join(EXT_DIR, "*.{c,h,rb}")).sort
|
|
38
|
+
combined = files.map { |f| Digest::MD5.file(f).hexdigest }.join
|
|
39
|
+
Digest::MD5.hexdigest(combined)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.digest_path
|
|
43
|
+
File.join(LIB_DIR, ".source_digest.#{RUBY_VERSION}_#{RUBY_PLATFORM}")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.write_digest
|
|
47
|
+
File.write(digest_path, source_digest)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.read_digest
|
|
51
|
+
File.read(digest_path).strip
|
|
52
|
+
rescue Errno::ENOENT
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FastCov
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :root, :ignored_path, :threads
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@root = Dir.pwd
|
|
9
|
+
@ignored_path = nil
|
|
10
|
+
@threads = true
|
|
11
|
+
@trackers = []
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def use(tracker_class, **options)
|
|
15
|
+
@trackers << {klass: tracker_class, options: options}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def trackers
|
|
19
|
+
@trackers
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def reset
|
|
23
|
+
initialize
|
|
24
|
+
self
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module FastCov
|
|
6
|
+
module ConstantExtractor
|
|
7
|
+
def self.extract(filename)
|
|
8
|
+
result = Prism.parse_file(filename)
|
|
9
|
+
constants = []
|
|
10
|
+
collect_constants(result.value, constants)
|
|
11
|
+
constants
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def collect_constants(node, constants)
|
|
18
|
+
case node
|
|
19
|
+
when Prism::ConstantPathNode
|
|
20
|
+
path = resolve_constant_path(node)
|
|
21
|
+
if path
|
|
22
|
+
constants << path
|
|
23
|
+
return
|
|
24
|
+
end
|
|
25
|
+
# Dynamic parent (e.g., expr::Foo) — fall through to walk children
|
|
26
|
+
when Prism::ConstantReadNode
|
|
27
|
+
constants << node.name.to_s
|
|
28
|
+
return
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
node.compact_child_nodes.each { |child| collect_constants(child, constants) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def resolve_constant_path(node)
|
|
35
|
+
parts = []
|
|
36
|
+
current = node
|
|
37
|
+
|
|
38
|
+
while current.is_a?(Prism::ConstantPathNode)
|
|
39
|
+
parts.unshift(current.name.to_s)
|
|
40
|
+
current = current.parent
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
if current.is_a?(Prism::ConstantReadNode)
|
|
44
|
+
parts.unshift(current.name.to_s)
|
|
45
|
+
elsif !current.nil?
|
|
46
|
+
return nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
parts.join("::")
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
data/lib/fast_cov/dev.rb
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Auto-compile entrypoint for local development with path: Gemfile references.
|
|
4
|
+
#
|
|
5
|
+
# Usage in a consuming project's Gemfile:
|
|
6
|
+
# gem "fast_cov", path: "../fast_cov", require: "fast_cov/dev"
|
|
7
|
+
#
|
|
8
|
+
# Compiles the C extension if:
|
|
9
|
+
# - It hasn't been compiled for this Ruby version yet
|
|
10
|
+
# - The C source files have changed since the last compilation
|
|
11
|
+
|
|
12
|
+
require_relative "compiler"
|
|
13
|
+
|
|
14
|
+
if FastCov::Compiler.stale?
|
|
15
|
+
$stdout.puts "[FastCov] Compiling extension for Ruby #{RUBY_VERSION} (#{RUBY_PLATFORM})..."
|
|
16
|
+
FastCov::Compiler.compile!
|
|
17
|
+
$stdout.puts "[FastCov] Compilation complete."
|
|
18
|
+
else
|
|
19
|
+
$stdout.puts "[FastCov] Extension up to date for Ruby #{RUBY_VERSION} (#{RUBY_PLATFORM})"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
require_relative "../fast_cov"
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FastCov
|
|
4
|
+
# Base class for trackers that record file paths during coverage.
|
|
5
|
+
#
|
|
6
|
+
# Provides common functionality:
|
|
7
|
+
# - Path filtering (root, ignored_path)
|
|
8
|
+
# - Thread-aware recording (threads option)
|
|
9
|
+
# - File collection and lifecycle management
|
|
10
|
+
#
|
|
11
|
+
# Descendants override hooks: on_start, on_stop, on_record
|
|
12
|
+
# Or implement start, stop, record directly without inheriting.
|
|
13
|
+
#
|
|
14
|
+
# Threading behavior:
|
|
15
|
+
# - threads: true -> record from ALL threads (global tracking)
|
|
16
|
+
# - threads: false -> only record from the thread that called start
|
|
17
|
+
class AbstractTracker
|
|
18
|
+
def initialize(config, **options)
|
|
19
|
+
@root = options.fetch(:root, config.root)
|
|
20
|
+
@ignored_path = options.fetch(:ignored_path, config.ignored_path)
|
|
21
|
+
@threads = options.fetch(:threads, config.threads)
|
|
22
|
+
@files = nil
|
|
23
|
+
@started_thread = nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Public API - called by FastCov framework
|
|
27
|
+
|
|
28
|
+
def start
|
|
29
|
+
@files = Set.new
|
|
30
|
+
@started_thread = Thread.current unless @threads
|
|
31
|
+
self.class.active = self
|
|
32
|
+
on_start
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def stop
|
|
36
|
+
self.class.active = nil
|
|
37
|
+
@started_thread = nil
|
|
38
|
+
on_stop
|
|
39
|
+
result = @files
|
|
40
|
+
@files = nil
|
|
41
|
+
result
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def record(abs_path)
|
|
45
|
+
return if !@threads && Thread.current != @started_thread
|
|
46
|
+
return unless abs_path.start_with?(@root)
|
|
47
|
+
return if @ignored_path && abs_path.start_with?(@ignored_path)
|
|
48
|
+
@files.add(abs_path) if on_record(abs_path)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Hooks for descendants - override as needed
|
|
52
|
+
|
|
53
|
+
def install; end
|
|
54
|
+
def on_start; end
|
|
55
|
+
def on_stop; end
|
|
56
|
+
|
|
57
|
+
def on_record(abs_path)
|
|
58
|
+
true
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
class << self
|
|
62
|
+
attr_accessor :active
|
|
63
|
+
|
|
64
|
+
def record(abs_path)
|
|
65
|
+
@active&.record(abs_path)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def reset
|
|
69
|
+
@active = nil
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FastCov
|
|
4
|
+
# Wraps the FastCov::Coverage C extension as a tracker plugin.
|
|
5
|
+
# Handles line coverage, allocation tracing, and constant resolution.
|
|
6
|
+
#
|
|
7
|
+
# Register via: config.use FastCov::CoverageTracker
|
|
8
|
+
# Options: root, ignored_path, threads, constant_references, allocations
|
|
9
|
+
class CoverageTracker
|
|
10
|
+
def initialize(config, **options)
|
|
11
|
+
@coverage = Coverage.new(
|
|
12
|
+
root: options.fetch(:root, config.root),
|
|
13
|
+
ignored_path: options.fetch(:ignored_path, config.ignored_path),
|
|
14
|
+
threads: options.fetch(:threads, config.threads),
|
|
15
|
+
constant_references: options.fetch(:constant_references, true),
|
|
16
|
+
allocations: options.fetch(:allocations, true)
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def start
|
|
21
|
+
@coverage.start
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def stop
|
|
25
|
+
Set.new(@coverage.stop.each_key)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "abstract_tracker"
|
|
4
|
+
|
|
5
|
+
module FastCov
|
|
6
|
+
# Tracks FactoryBot factory definition files when factories are used.
|
|
7
|
+
#
|
|
8
|
+
# Factory files are typically loaded at boot time, before coverage starts.
|
|
9
|
+
# This tracker intercepts FactoryBot.factories.find (called by create/build)
|
|
10
|
+
# to record the source file where each factory was defined.
|
|
11
|
+
#
|
|
12
|
+
# Register via: config.use FastCov::FactoryBotTracker
|
|
13
|
+
# Options: root, ignored_path, threads (all default from config)
|
|
14
|
+
class FactoryBotTracker < AbstractTracker
|
|
15
|
+
def install
|
|
16
|
+
unless defined?(::FactoryBot)
|
|
17
|
+
raise LoadError, "FactoryBotTracker requires the factory_bot gem to be installed"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
::FactoryBot.factories.singleton_class.prepend(RegistryPatch)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
module RegistryPatch
|
|
24
|
+
def find(name)
|
|
25
|
+
factory = super
|
|
26
|
+
FastCov::FactoryBotTracker.record_factory_files(factory)
|
|
27
|
+
factory
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
def record_factory_files(factory)
|
|
33
|
+
return unless @active
|
|
34
|
+
|
|
35
|
+
definition = factory.definition
|
|
36
|
+
declarations = definition.instance_variable_get(:@declarations)
|
|
37
|
+
return unless declarations
|
|
38
|
+
|
|
39
|
+
declarations.each do |decl|
|
|
40
|
+
block = decl.instance_variable_get(:@block)
|
|
41
|
+
next unless block.is_a?(Proc)
|
|
42
|
+
|
|
43
|
+
location = block.source_location
|
|
44
|
+
next unless location
|
|
45
|
+
|
|
46
|
+
file_path = location[0]
|
|
47
|
+
@active.record(file_path)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|