fast_cov 0.3.2 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8d5eb20089cfe981168592cd5672e1919746a5186c61f151aff87eea7319eb1c
4
- data.tar.gz: af45acb129fd680beaa1a906248ad1aedf03e10a5d40d02fbeaa0971e9112b44
3
+ metadata.gz: 1b276767c3b77601bb2cb06febd1476fcab8c5f94fc5b714842d0ddcce6a0fb9
4
+ data.tar.gz: 44bd1a0b268802dc8c7b6e5687bf52fcb969849191fdb0d5471cab87350e43cc
5
5
  SHA512:
6
- metadata.gz: 97cdc30f83fb1970d97d6cddc04758463043a85ee81ccd5a22011e0b017a04d8b9b82be90931b42bdf5565ef163ac84cc1544ec43ecae2cc1727041770adfc52
7
- data.tar.gz: baa28e4ae365439250404b285eb0577a167de1cb907a3c37b33f09d3a99bf53bf4e6ca0ac6fd2ff84360a048f2795de7ca709ffd11c1236b0846ae66a4f4a75a
6
+ metadata.gz: 178d1061ae15c475719b2da16297df134be25798a6bc22ec81aece28a180d7db2e5914296fc54596d3ea858b57538be177dc96f203d2625004c2f369078b6d44
7
+ data.tar.gz: abdf03b462db505b675d29b332ddc933a6af415cef35a474608d9d8330993b52f294b84923a00dd7c9371bcd7311b3e1518e1ab9772088fbb529740ff74fbfbc
data/README.md CHANGED
@@ -6,7 +6,7 @@ FastCov hooks directly into the Ruby VM's event system, avoiding the overhead of
6
6
 
7
7
  ## Requirements
8
8
 
9
- - Ruby >= 3.4.0 (MRI only)
9
+ - Ruby >= 3.2.0 (MRI only)
10
10
  - macOS or Linux
11
11
 
12
12
  ## Installation
@@ -32,7 +32,7 @@ coverage = FastCov::CoverageMap.new
32
32
  coverage.root = File.expand_path("app")
33
33
  coverage.use(FastCov::FileTracker)
34
34
 
35
- result = coverage.start do
35
+ result = coverage.build do
36
36
  # ... run a test ...
37
37
  end
38
38
 
@@ -53,6 +53,8 @@ coverage.ignored_paths = Rails.root.join("vendor")
53
53
 
54
54
  coverage.use(FastCov::FileTracker)
55
55
  coverage.use(FastCov::FactoryBotTracker)
56
+ coverage.use(FastCov::ConstGetTracker)
57
+ coverage.use(FastCov::FixtureKitTracker)
56
58
  ```
57
59
 
58
60
  ### Options
@@ -66,9 +68,13 @@ coverage.use(FastCov::FactoryBotTracker)
66
68
  ### Lifecycle
67
69
 
68
70
  ```ruby
69
- coverage.start # starts tracking and returns the CoverageMap
70
- coverage.stop # stops tracking and returns a Set
71
- coverage.start { ... } # block form: start, yield, stop
71
+ coverage.start # starts tracking, returns self
72
+ result = coverage.stop # stops tracking, returns a Set
73
+
74
+ # Block form: start, yield, stop
75
+ result = coverage.build do
76
+ # ...
77
+ end
72
78
  ```
73
79
 
74
80
  Native line coverage is always enabled. Extra trackers registered with `use` are additive.
@@ -77,7 +83,11 @@ Native line coverage is always enabled. Extra trackers registered with `use` are
77
83
 
78
84
  ### FileTracker
79
85
 
80
- Tracks files read from disk during coverage, including JSON, YAML, ERB templates, and any file accessed via `File.read` or read-mode `File.open`.
86
+ Tracks files read from disk during coverage, including YAML, JSON, ERB templates, and any file accessed via `File.read`, read-mode `File.open`, `YAML.load_file`, `YAML.safe_load_file`, or `YAML.unsafe_load_file`.
87
+
88
+ The YAML methods are patched directly to handle Bootsnap's compile cache, which bypasses `File.open` for YAML files.
89
+
90
+ When a file is read indirectly (e.g., `YAML.load_file` calling through Psych), the tracker walks the caller stack to find the first in-root frame and creates a connected dependency.
81
91
 
82
92
  ```ruby
83
93
  coverage.use(FastCov::FileTracker)
@@ -107,62 +117,112 @@ This catches patterns such as:
107
117
 
108
118
  It does not catch direct constant references such as `Foo::Bar` in source code.
109
119
 
110
- ## Low-level native coverage
120
+ ### FixtureKitTracker
111
121
 
112
- `FastCov::Coverage` is still available as a low-level primitive:
122
+ Tracks [fixture_kit](https://github.com/Gusto/fixture_kit) fixture definition files when fixtures are used. Requires fixture_kit >= 0.14.0.
123
+
124
+ Fixture definitions run once during cache generation (`before(:context)`), then every test replays cached SQL without executing Ruby. This tracker uses fixture_kit's callback hooks to:
125
+
126
+ 1. Track files touched during fixture generation and create connected dependencies
127
+ 2. Record fixture definition files (including parent chain) when tests mount fixtures
113
128
 
114
129
  ```ruby
115
- cov = FastCov::Coverage.new(
116
- root: "/repo/app",
117
- ignored_paths: ["/repo/app/vendor"],
118
- threads: true
119
- )
130
+ coverage.use(FastCov::FixtureKitTracker)
120
131
  ```
121
132
 
122
- This API is mainly useful for internal use and low-level tests. `CoverageMap` is the intended public orchestration API.
123
-
124
133
  ## StaticMap
125
134
 
126
- `FastCov::StaticMap` is a build-time API for static dependency mapping. It parses Ruby files with Prism, resolves literal constant references, and builds a direct dependency graph. Transitive closures are computed lazily on demand.
135
+ `FastCov::StaticMap` is a build-time API for static dependency mapping. It parses Ruby files with Prism, resolves literal constant references, and builds a dependency graph. Transitive closures are computed lazily on demand.
127
136
 
128
137
  ```ruby
129
138
  static_map = FastCov::StaticMap.new(root: Rails.root)
130
139
  static_map.build("spec/**/*_spec.rb")
131
140
 
132
141
  # Direct dependencies for a single file
133
- static_map.dependencies("/app/spec/models/user_spec.rb")
134
- # => ["/app/app/models/user.rb"]
142
+ static_map.direct_dependencies("spec/models/user_spec.rb")
143
+ # => ["app/models/user.rb"]
135
144
 
136
- # Transitive closure (computed and cached on first call)
137
- static_map.transitive_dependencies("/app/spec/models/user_spec.rb")
138
- # => ["/app/app/models/account.rb", "/app/app/models/user.rb"]
139
-
140
- # Raw direct graph
141
- static_map.direct_graph
142
- # => { "/app/spec/models/user_spec.rb" => ["/app/app/models/user.rb"], ... }
145
+ # Transitive dependencies (computed and cached on first call)
146
+ static_map.dependencies("spec/models/user_spec.rb")
147
+ # => ["app/models/user.rb", "app/models/account.rb"]
143
148
  ```
144
149
 
145
150
  The instance caches constant resolution results, so reusing the same instance across multiple `build` calls is efficient.
146
151
 
147
- #### Options
152
+ ### Options
148
153
 
149
154
  | Option | Type | Default | Description |
150
155
  |---|---|---|---|
151
156
  | `root` | String or Pathname | required | Absolute project root. Only resolved files under this path are included. |
152
- | `ignored_paths` | String or Array<String> | `[]` | Files or directories to exclude from the graph and recursive traversal. |
153
- | `*patterns` (on `build`) | String(s) or Array | required | File paths or globs to traverse. Relative paths are expanded against `root`. |
157
+ | `ignored_paths` | String or Array | `[]` | Files or directories to exclude from the graph and recursive traversal. |
158
+ | `concurrency` | Integer | `Etc.nprocessors` | Number of threads for parallel file parsing. |
154
159
 
155
- #### How it works
160
+ ### How it works
156
161
 
157
- - `build` traverses reachable files and stores a direct dependency graph
158
- - `dependencies` returns direct dependencies for a file
159
- - `transitive_dependencies` computes and caches the transitive closure lazily
162
+ - `build(*patterns)` traverses reachable files and stores a direct dependency graph
163
+ - `direct_dependencies(file)` returns direct dependencies for a file
164
+ - `dependencies(file)` computes and caches the transitive closure lazily
160
165
  - Constant resolution results are cached and reused across `build` calls
161
166
  - Resolves each reference from most-specific lexical candidate to least-specific
162
167
  - Uses `const_defined?` and `const_source_location` to resolve literal constant references to source files
163
168
 
164
169
  This is intended for a booted application process. It requires constants to be eager-loaded. It will not see dynamic constant lookups that are not expressed as literal constants in the source.
165
170
 
171
+ ## TestMap
172
+
173
+ `FastCov::TestMap` handles test mapping serialization and aggregation. It accumulates mappings from test runs, writes gzipped fragment files, and merges fragments from multiple CI nodes.
174
+
175
+ ### Accumulating mappings
176
+
177
+ ```ruby
178
+ test_map = FastCov::TestMap.new
179
+
180
+ # Record which files each test depends on
181
+ test_map.add("spec/models/" => coverage_map.stop)
182
+
183
+ # Query: which tests cover this file?
184
+ test_map.dependencies("app/models/user.rb")
185
+ # => ["spec/models/"]
186
+
187
+ # Write gzipped fragment for later aggregation
188
+ test_map.dump("tmp/test_mapping.node_0.gz")
189
+ ```
190
+
191
+ ### Aggregating fragments
192
+
193
+ Merge fragments from multiple CI nodes via k-way merge:
194
+
195
+ ```ruby
196
+ aggregator = FastCov::TestMap.aggregate(Dir["tmp/test_mapping.*.gz"])
197
+
198
+ # Hook into progress events
199
+ aggregator.on(:sort) { |fragments, batches| puts "#{fragments} fragments -> #{batches} batches" }
200
+ aggregator.on(:sorted) { |elapsed| puts "Sorted in #{elapsed.round(2)}s" }
201
+ aggregator.on(:merge) { |processed, total| print "#{processed}/#{total}\r" }
202
+ aggregator.on(:merged) { |files, elapsed| puts "Merged #{files} files in #{elapsed.round(2)}s" }
203
+
204
+ # Iterate in batches — yields Hash of { file => [deps] }
205
+ aggregator.each(10_000) do |batch|
206
+ database.bulk_write(batch)
207
+ end
208
+ ```
209
+
210
+ ### Options
211
+
212
+ | Option | Type | Default | Description |
213
+ |---|---|---|---|
214
+ | `readers:` | Integer | `min(100, ulimit/2)` | Max concurrent readers for k-way merge. Auto-detected from OS file descriptor limit. |
215
+
216
+ ### Fragment format
217
+
218
+ Tab-delimited, gzipped. One line per source file, first column is the file, remaining columns are dependencies:
219
+
220
+ ```
221
+ source_file\tdep1\tdep2\tdep3
222
+ ```
223
+
224
+ Aggregation owns sorting — fragments are unsorted, intermediates are sorted during the merge process using pure Ruby (no shell commands).
225
+
166
226
  ## Writing custom trackers
167
227
 
168
228
  There are two approaches: a minimal custom tracker, or inheriting from `AbstractTracker`.
@@ -200,6 +260,7 @@ end
200
260
  - thread-aware recording
201
261
  - lifecycle management
202
262
  - class-level `record` dispatch for patched hooks
263
+ - caller stack traversal via `Utils.resolve_caller` for indirect calls
203
264
 
204
265
  ```ruby
205
266
  class MyTracker < FastCov::AbstractTracker
@@ -209,7 +270,8 @@ class MyTracker < FastCov::AbstractTracker
209
270
 
210
271
  module MyPatch
211
272
  def some_method(...)
212
- MyTracker.record { some_file_path }
273
+ # record(path) auto-resolves the caller via stack traversal
274
+ MyTracker.record(some_file_path)
213
275
  super
214
276
  end
215
277
  end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+ require "zlib"
5
+
6
+ module FastCov
7
+ class TestMap
8
+ # Handles k-way merge of sorted fragment files.
9
+ # Created by TestMap.aggregate, not instantiated directly.
10
+ #
11
+ # Usage:
12
+ # aggregator = FastCov::TestMap.aggregate(Dir["tmp/test_mapping.*.gz"])
13
+ # aggregator.on(:sorted) { |elapsed| puts "Sorted in #{elapsed.round(2)}s" }
14
+ # aggregator.on(:merged) { |files, elapsed| puts "Merged #{files} files in #{elapsed.round(2)}s" }
15
+ # aggregator.each(10_000) { |batch| database.bulk_write(batch) }
16
+ class Aggregator
17
+ def initialize(fragment_paths, max_readers)
18
+ @fragment_paths = fragment_paths
19
+ @max_readers = max_readers
20
+ @hooks = {}
21
+ end
22
+
23
+ # Register a callback for an aggregation event.
24
+ #
25
+ # Events:
26
+ # :sort — before sorting. Yields (fragment_count, batch_count)
27
+ # :sorted — after sorting. Yields (elapsed)
28
+ # :merge — during merge. Yields (processed_lines, total_lines)
29
+ # :merged — after merging. Yields (file_count, elapsed)
30
+ def on(event, &block)
31
+ @hooks[event] = block
32
+ self
33
+ end
34
+
35
+ # Iterate over merged results.
36
+ # Yields a Hash of { file => dependencies } per batch.
37
+ # Default batch_size is 1.
38
+ def each(batch_size = 1, &block)
39
+ raise ArgumentError, "each requires a block" unless block
40
+ return if @fragment_paths.empty?
41
+
42
+ Dir.mktmpdir("fastcov") do |tmpdir|
43
+ intermediates, total_lines = create_intermediates(tmpdir)
44
+ readers = intermediates.map { |f| Reader.new(f) }
45
+ kway_merge(readers, batch_size, total_lines, &block)
46
+ ensure
47
+ readers&.each(&:close)
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def emit(event, *args)
54
+ @hooks[event]&.call(*args)
55
+ end
56
+
57
+ def measure
58
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
59
+ result = yield
60
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
61
+ [result, elapsed]
62
+ end
63
+
64
+ def create_intermediates(intermediates_dir)
65
+ batch_size = (@fragment_paths.size.to_f / @max_readers).ceil
66
+ batches = @fragment_paths.each_slice(batch_size).to_a
67
+
68
+ emit(:sort, @fragment_paths.size, batches.size)
69
+
70
+ total_lines = 0
71
+ intermediates, elapsed = measure do
72
+ batches.each_with_index.map do |batch, i|
73
+ intermediate = File.join(intermediates_dir, "intermediate_#{i}.txt")
74
+ lines = batch.flat_map { |f| Zlib::GzipReader.open(f) { |gz| gz.readlines } }
75
+ total_lines += lines.size
76
+ lines.sort!
77
+ File.write(intermediate, lines.join)
78
+ intermediate
79
+ end
80
+ end
81
+
82
+ emit(:sorted, elapsed)
83
+ [intermediates, total_lines]
84
+ end
85
+
86
+ def kway_merge(readers, batch_size, total_lines, &block)
87
+ unique_files = 0
88
+ processed_lines = 0
89
+ batch = {}
90
+
91
+ _, elapsed = measure do
92
+ loop do
93
+ active = readers.reject(&:exhausted?)
94
+ break if active.empty?
95
+
96
+ min_path = active.map(&:file_path).min
97
+
98
+ merged = Set.new
99
+ active.each do |reader|
100
+ if reader.file_path == min_path
101
+ processed_lines += 1
102
+ merged.merge(reader.dependencies)
103
+ reader.advance
104
+ end
105
+ end
106
+
107
+ emit(:merge, processed_lines, total_lines)
108
+ unique_files += 1
109
+
110
+ batch[min_path] = merged.to_a.sort
111
+ if batch.size >= batch_size
112
+ block.call(batch)
113
+ batch = {}
114
+ end
115
+ end
116
+ end
117
+
118
+ block.call(batch) unless batch.empty?
119
+
120
+ emit(:merged, unique_files, elapsed)
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zlib"
4
+
5
+ module FastCov
6
+ class TestMap
7
+ # Reads a single sorted fragment file (gzipped or plain text).
8
+ # Merges consecutive entries with the same file path, which occur when
9
+ # multiple fragments are concatenated and sorted into an intermediate.
10
+ class Reader
11
+ attr_reader :file_path, :dependencies
12
+
13
+ def initialize(path)
14
+ @io = path.to_s.end_with?(".gz") ? Zlib::GzipReader.open(path) : File.open(path)
15
+ @exhausted = false
16
+ @next_file_path = nil
17
+ @next_dependencies = nil
18
+ read_line
19
+ advance
20
+ end
21
+
22
+ def exhausted?
23
+ @exhausted
24
+ end
25
+
26
+ def advance
27
+ if @next_file_path.nil?
28
+ @exhausted = true
29
+ @file_path = nil
30
+ @dependencies = []
31
+ return
32
+ end
33
+
34
+ @file_path = @next_file_path
35
+ @dependencies = @next_dependencies
36
+ read_line
37
+
38
+ # Merge consecutive lines with the same file path
39
+ while @next_file_path == @file_path
40
+ @dependencies.concat(@next_dependencies)
41
+ read_line
42
+ end
43
+ end
44
+
45
+ def close
46
+ @io.close
47
+ end
48
+
49
+ private
50
+
51
+ def read_line
52
+ line = @io.gets
53
+ if line.nil?
54
+ @next_file_path = nil
55
+ @next_dependencies = nil
56
+ return
57
+ end
58
+
59
+ parts = line.chomp.split("\t")
60
+ @next_file_path = parts[0]
61
+ @next_dependencies = parts[1..] || []
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "zlib"
5
+
6
+ module FastCov
7
+ # In-memory test mapping that records which files each test depends on.
8
+ # Can be dumped to a gzipped TSV fragment file for later aggregation.
9
+ #
10
+ # Usage:
11
+ # # Accumulate mappings (e.g., in an RSpec formatter)
12
+ # test_map = FastCov::TestMap.new
13
+ # test_map.add("spec/models/user_spec.rb" => coverage_result)
14
+ # test_map.dump("tmp/test_mapping.node_0.gz")
15
+ #
16
+ # # Query mappings
17
+ # test_map.dependencies("app/models/user.rb")
18
+ # # => ["spec/models/user_spec.rb"]
19
+ #
20
+ # # Aggregate fragments from multiple nodes
21
+ # aggregator = FastCov::TestMap.aggregate(Dir["tmp/test_mapping.*.gz"])
22
+ # aggregator.on(:sorted) { |elapsed| puts "Sorted in #{elapsed.round(2)}s" }
23
+ # aggregator.on(:merged) { |files, elapsed| puts "Merged #{files} files in #{elapsed.round(2)}s" }
24
+ # aggregator.each(10_000) { |batch| database.bulk_write(batch) }
25
+ class TestMap
26
+ autoload :Aggregator, File.expand_path("test_map/aggregator", __dir__)
27
+ autoload :Reader, File.expand_path("test_map/reader", __dir__)
28
+
29
+ DEFAULT_MAX_READERS = [100, Process.getrlimit(Process::RLIMIT_NOFILE).first / 2].min
30
+
31
+ def initialize
32
+ @mapping = {}
33
+ end
34
+
35
+ # Record test -> dependency mappings.
36
+ # Accepts a Hash of { test_path => dependencies }.
37
+ def add(mappings)
38
+ mappings.each do |test_path, deps|
39
+ deps.each do |dep|
40
+ next if dep == test_path
41
+
42
+ (@mapping[dep] ||= Set.new) << test_path
43
+ end
44
+ end
45
+ end
46
+
47
+ # Returns the test paths that depend on the given file.
48
+ def dependencies(file)
49
+ @mapping[file]&.to_a
50
+ end
51
+
52
+ # Write the accumulated mappings as a gzipped TSV fragment.
53
+ def dump(path)
54
+ FileUtils.mkdir_p(File.dirname(path))
55
+
56
+ lines = @mapping.map { |file, deps| "#{file}\t#{deps.to_a.join("\t")}\n" }
57
+ Zlib::GzipWriter.open(path) { |gz| gz.write(lines.join) }
58
+ end
59
+
60
+ # Number of unique source files mapped.
61
+ def size
62
+ @mapping.size
63
+ end
64
+
65
+ # Create an Aggregator for merging fragment files.
66
+ # Accepts file paths or glob patterns.
67
+ def self.aggregate(*patterns, readers: DEFAULT_MAX_READERS)
68
+ fragment_paths = patterns.flatten.flat_map { |p| p.include?("*") ? Dir.glob(p).sort : p }
69
+ Aggregator.new(fragment_paths, readers)
70
+ end
71
+ end
72
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FastCov
4
- VERSION = "0.3.2"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/fast_cov.rb CHANGED
@@ -13,4 +13,5 @@ module FastCov
13
13
  autoload :ConstGetTracker, File.expand_path("fast_cov/trackers/const_get_tracker", __dir__)
14
14
  autoload :FixtureKitTracker, File.expand_path("fast_cov/trackers/fixture_kit_tracker", __dir__)
15
15
  autoload :StaticMap, File.expand_path("fast_cov/static_map", __dir__)
16
+ autoload :TestMap, File.expand_path("fast_cov/test_map", __dir__)
16
17
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fast_cov
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.2
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ngan Pham
@@ -115,6 +115,9 @@ files:
115
115
  - lib/fast_cov/dev.rb
116
116
  - lib/fast_cov/static_map.rb
117
117
  - lib/fast_cov/static_map/reference_extractor.rb
118
+ - lib/fast_cov/test_map.rb
119
+ - lib/fast_cov/test_map/aggregator.rb
120
+ - lib/fast_cov/test_map/reader.rb
118
121
  - lib/fast_cov/trackers/abstract_tracker.rb
119
122
  - lib/fast_cov/trackers/const_get_tracker.rb
120
123
  - lib/fast_cov/trackers/factory_bot_tracker.rb