fast_cov 0.2.1 → 0.3.1

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: 3d41bffc0dc2cbd1cf3d92d8296c61318b544f4a53d2709868cecc7ff1013cbb
4
- data.tar.gz: 75896a982a456bc9715a1f62895e295ad969afb99069e0781cb21d670ed4a4ae
3
+ metadata.gz: ba4073b332a0c7f0837d5cf954b9ad98353721e6350c24969541819a42a27c65
4
+ data.tar.gz: c85de804a17e0f1bd7b21a370e61dbe0d59dc2bb5ce02d84f827c500d6816b5a
5
5
  SHA512:
6
- metadata.gz: 30a3b33ddca11202bdc7dfd138d847d0cc0d2366ba3915f57a5063e157ee756661da5599bcbd1348d2a9723cd89f6f3ed21acc57da2ea497f3005d4aed70429a
7
- data.tar.gz: 3ac7f28ac5e49176cd7e3c8373f8b8fa5a1375fd9a2561d5d348c3081246f24c71a65b275c306a675555235d7e9caeaaaf2df4ccda83508ecc2d4f95a1c1f8cc
6
+ metadata.gz: f080c26c0a0813a10555885ac162e7662519641501d5d499bebbb1315414ba3469770ac66d95576d6209cc7e9c8b4181fa43c6a729c3d4ccd2c282a1394a86f6
7
+ data.tar.gz: 0ff5f9c3053015028416a2d5190785542abf1bfe0ce6fe2c0d8dae5ab16e555419f5a8b350980119ebf87065b1166b5f700f6131c582c4df9c36b5eb3a0b5f8e
data/README.md CHANGED
@@ -121,6 +121,48 @@ cov = FastCov::Coverage.new(
121
121
 
122
122
  This API is mainly useful for internal use and low-level tests. `CoverageMap` is the intended public orchestration API.
123
123
 
124
+ ## StaticMap
125
+
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.
127
+
128
+ ```ruby
129
+ static_map = FastCov::StaticMap.new(root: Rails.root)
130
+ static_map.build("spec/**/*_spec.rb")
131
+
132
+ # Direct dependencies for a single file
133
+ static_map.dependencies("/app/spec/models/user_spec.rb")
134
+ # => ["/app/app/models/user.rb"]
135
+
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"], ... }
143
+ ```
144
+
145
+ The instance caches constant resolution results, so reusing the same instance across multiple `build` calls is efficient.
146
+
147
+ #### Options
148
+
149
+ | Option | Type | Default | Description |
150
+ |---|---|---|---|
151
+ | `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`. |
154
+
155
+ #### How it works
156
+
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
160
+ - Constant resolution results are cached and reused across `build` calls
161
+ - Resolves each reference from most-specific lexical candidate to least-specific
162
+ - Uses `const_defined?` and `const_source_location` to resolve literal constant references to source files
163
+
164
+ 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
+
124
166
  ## Writing custom trackers
125
167
 
126
168
  There are two approaches: a minimal custom tracker, or inheriting from `AbstractTracker`.
@@ -167,7 +209,7 @@ class MyTracker < FastCov::AbstractTracker
167
209
 
168
210
  module MyPatch
169
211
  def some_method(...)
170
- MyTracker.record(some_file_path)
212
+ MyTracker.record { some_file_path }
171
213
  super
172
214
  end
173
215
  end
@@ -140,80 +140,6 @@ static void on_line_event(rb_event_flag_t event, VALUE self_data, VALUE self,
140
140
  record_impacted_file(data, filename);
141
141
  }
142
142
 
143
- // ---- Utils module methods (FastCov::Utils) ------------------------------
144
-
145
- // Utils.path_within?(path, directory) -> true/false
146
- // Check if path is within directory, correctly handling:
147
- // - Trailing slashes on directory
148
- // - Sibling directories with longer names (e.g., /a/b/c vs /a/b/cd)
149
- static VALUE utils_path_within(VALUE self, VALUE path, VALUE directory) {
150
- Check_Type(path, T_STRING);
151
- Check_Type(directory, T_STRING);
152
-
153
- // Freeze strings to prevent GC compaction from moving them
154
- rb_str_freeze(path);
155
- rb_str_freeze(directory);
156
-
157
- bool result = fast_cov_is_within_root(
158
- RSTRING_PTR(path), RSTRING_LEN(path),
159
- RSTRING_PTR(directory), RSTRING_LEN(directory));
160
-
161
- return result ? Qtrue : Qfalse;
162
- }
163
-
164
- // Utils.relativize_paths(set, root) -> set
165
- // Mutates set in place: converts absolute paths to relative paths from root.
166
- // Paths not within root are left unchanged.
167
- static VALUE utils_relativize_paths(VALUE self, VALUE set, VALUE root) {
168
- Check_Type(root, T_STRING);
169
-
170
- // Freeze root to prevent GC from moving it during compaction
171
- rb_str_freeze(root);
172
-
173
- const char *root_ptr = RSTRING_PTR(root);
174
- long root_len = RSTRING_LEN(root);
175
-
176
- // Normalize: strip trailing slash for offset calculation
177
- long effective_root_len = root_len;
178
- if (effective_root_len > 0 && root_ptr[effective_root_len - 1] == '/') {
179
- effective_root_len--;
180
- }
181
-
182
- // Collect paths to transform (can't modify set while iterating)
183
- VALUE paths = rb_funcall(set, rb_intern("to_a"), 0);
184
- long num_paths = RARRAY_LEN(paths);
185
-
186
- for (long i = 0; i < num_paths; i++) {
187
- VALUE abs_path = rb_ary_entry(paths, i);
188
- if (!RB_TYPE_P(abs_path, T_STRING)) continue;
189
-
190
- // Freeze to prevent GC moving it
191
- rb_str_freeze(abs_path);
192
-
193
- const char *path_ptr = RSTRING_PTR(abs_path);
194
- long path_len = RSTRING_LEN(abs_path);
195
-
196
- // Use proper within_root check
197
- if (!fast_cov_is_within_root(path_ptr, path_len, root_ptr, root_len)) {
198
- continue;
199
- }
200
-
201
- // Calculate offset (skip root + separator)
202
- long offset = effective_root_len;
203
- if (offset < path_len && path_ptr[offset] == '/') offset++;
204
-
205
- // Create relative path
206
- VALUE rel_path = rb_str_substr(abs_path, offset, path_len - offset);
207
-
208
- // Delete old path, add new path
209
- rb_funcall(set, rb_intern("delete"), 1, abs_path);
210
- rb_funcall(set, rb_intern("add"), 1, rel_path);
211
- }
212
-
213
- RB_GC_GUARD(paths);
214
- return set;
215
- }
216
-
217
143
  // ---- Ruby instance methods ----------------------------------------------
218
144
 
219
145
  static VALUE fast_cov_initialize(int argc, VALUE *argv, VALUE self) {
@@ -366,8 +292,4 @@ void Init_fast_cov(void) {
366
292
  rb_define_method(cCoverage, "start", fast_cov_start, 0);
367
293
  rb_define_method(cCoverage, "stop", fast_cov_stop, 0);
368
294
 
369
- // FastCov::Utils module (C-defined methods)
370
- VALUE mUtils = rb_define_module_under(mFastCov, "Utils");
371
- rb_define_module_function(mUtils, "path_within?", utils_path_within, 2);
372
- rb_define_module_function(mUtils, "relativize_paths", utils_relativize_paths, 2);
373
295
  }
@@ -52,11 +52,21 @@ module FastCov
52
52
  self
53
53
  end
54
54
 
55
- def start
56
- if @started
57
- raise "CoverageMap is already started" if block_given?
58
- return self
55
+ def build
56
+ raise ArgumentError, "build requires a block" unless block_given?
57
+ raise "CoverageMap is already started" if @started
58
+
59
+ start
60
+ begin
61
+ yield
62
+ ensure
63
+ result = stop
59
64
  end
65
+ result
66
+ end
67
+
68
+ def start
69
+ return self if @started
60
70
 
61
71
  begin
62
72
  @native_coverage = Coverage.new(
@@ -72,17 +82,7 @@ module FastCov
72
82
  raise
73
83
  end
74
84
 
75
- if block_given?
76
- result = nil
77
- begin
78
- yield
79
- ensure
80
- result = stop
81
- end
82
- result
83
- else
84
- self
85
- end
85
+ self
86
86
  end
87
87
 
88
88
  def stop
@@ -142,7 +142,7 @@ module FastCov
142
142
  end
143
143
 
144
144
  def absolute_path?(path)
145
- File.absolute_path?(path.to_s)
145
+ File.absolute_path?(path)
146
146
  end
147
147
 
148
148
  def normalize_path(path)
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module FastCov
6
+ class StaticMap
7
+ module ReferenceExtractor
8
+ class << self
9
+ def extract(filename)
10
+ result = Prism.parse_file(filename)
11
+ return FastCov::StaticMap::EMPTY_ARRAY unless result.success?
12
+
13
+ reference_groups = []
14
+ seen_groups = {}
15
+ collect_constants(result.value, reference_groups, seen_groups, [])
16
+
17
+ reference_groups.map do |group|
18
+ group.map { |const_name| -const_name }.freeze
19
+ end.freeze
20
+ end
21
+
22
+ private
23
+
24
+ def collect_constants(node, reference_groups, seen_groups, nesting_prefixes)
25
+ case node
26
+ when Prism::ModuleNode
27
+ module_name = constant_name_for_nesting(node.constant_path)
28
+ next_prefixes = next_nesting_prefixes(nesting_prefixes, module_name)
29
+ node.body&.compact_child_nodes&.each do |child|
30
+ collect_constants(child, reference_groups, seen_groups, next_prefixes)
31
+ end
32
+ return
33
+ when Prism::ClassNode
34
+ class_name = constant_name_for_nesting(node.constant_path)
35
+ next_prefixes = next_nesting_prefixes(nesting_prefixes, class_name)
36
+ add_with_nesting(node.superclass, reference_groups, seen_groups, nesting_prefixes) if node.superclass
37
+ node.body&.compact_child_nodes&.each do |child|
38
+ collect_constants(child, reference_groups, seen_groups, next_prefixes)
39
+ end
40
+ return
41
+ when Prism::SingletonClassNode
42
+ node.body&.compact_child_nodes&.each do |child|
43
+ collect_constants(child, reference_groups, seen_groups, nesting_prefixes)
44
+ end
45
+ return
46
+ when Prism::ConstantPathNode
47
+ add_with_nesting(node, reference_groups, seen_groups, nesting_prefixes)
48
+ return
49
+ when Prism::ConstantReadNode
50
+ add_reference_group(
51
+ expand_with_nesting(node.name.to_s, nesting_prefixes),
52
+ reference_groups,
53
+ seen_groups
54
+ )
55
+ return
56
+ end
57
+
58
+ node.compact_child_nodes.each do |child|
59
+ collect_constants(child, reference_groups, seen_groups, nesting_prefixes)
60
+ end
61
+ end
62
+
63
+ def add_with_nesting(node, reference_groups, seen_groups, nesting_prefixes)
64
+ candidates = candidates_for_node(node, nesting_prefixes)
65
+ add_reference_group(candidates, reference_groups, seen_groups)
66
+ end
67
+
68
+ def candidates_for_node(node, nesting_prefixes)
69
+ case node
70
+ when Prism::ConstantPathNode
71
+ path = resolve_constant_path(node)
72
+ return FastCov::StaticMap::EMPTY_ARRAY unless path
73
+
74
+ if path.start_with?("::")
75
+ [path.delete_prefix("::")]
76
+ else
77
+ expand_with_nesting(path, nesting_prefixes)
78
+ end
79
+ when Prism::ConstantReadNode
80
+ expand_with_nesting(node.name.to_s, nesting_prefixes)
81
+ else
82
+ FastCov::StaticMap::EMPTY_ARRAY
83
+ end
84
+ end
85
+
86
+ def add_reference_group(candidates, reference_groups, seen_groups)
87
+ return if candidates.empty?
88
+
89
+ key = candidates.join("\0")
90
+ return if seen_groups[key]
91
+
92
+ seen_groups[key] = true
93
+ reference_groups << candidates
94
+ end
95
+
96
+ def expand_with_nesting(const_name, nesting_prefixes)
97
+ candidates = []
98
+ seen_candidates = {}
99
+
100
+ nesting_prefixes.reverse_each do |prefix|
101
+ add_unique("#{prefix}::#{const_name}", candidates, seen_candidates)
102
+ end
103
+
104
+ add_unique(const_name, candidates, seen_candidates)
105
+ candidates
106
+ end
107
+
108
+ def next_nesting_prefixes(nesting_prefixes, nested_name)
109
+ return nesting_prefixes unless nested_name
110
+
111
+ if nesting_prefixes.empty?
112
+ [nested_name]
113
+ else
114
+ nesting_prefixes + ["#{nesting_prefixes.last}::#{nested_name}"]
115
+ end
116
+ end
117
+
118
+ def constant_name_for_nesting(node)
119
+ case node
120
+ when Prism::ConstantPathNode
121
+ resolve_constant_path(node)&.delete_prefix("::")
122
+ when Prism::ConstantReadNode
123
+ node.name.to_s
124
+ end
125
+ end
126
+
127
+ def add_unique(const_name, constants, seen)
128
+ return if seen[const_name]
129
+
130
+ seen[const_name] = true
131
+ constants << const_name
132
+ end
133
+
134
+ def resolve_constant_path(node)
135
+ parts = []
136
+ current = node
137
+
138
+ while current.is_a?(Prism::ConstantPathNode)
139
+ parts.unshift(current.name.to_s)
140
+ current = current.parent
141
+ end
142
+
143
+ if current.is_a?(Prism::ConstantReadNode)
144
+ parts.unshift(current.name.to_s)
145
+ elsif current.nil?
146
+ return "::#{parts.join("::")}"
147
+ else
148
+ return nil
149
+ end
150
+
151
+ parts.join("::")
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FastCov
4
+ class StaticMap
5
+ EMPTY_ARRAY = [].freeze
6
+
7
+ autoload :ReferenceExtractor, File.expand_path("static_map/reference_extractor", __dir__)
8
+
9
+ def initialize(root:, ignored_paths: [], concurrency: Etc.nprocessors)
10
+ @root = share_path(root)
11
+ @root_prefix = -"#{@root}/"
12
+ @ignored_paths = normalize_ignored_paths(ignored_paths)
13
+ @concurrency = concurrency
14
+ @resolved_file_by_const = {}
15
+ @closure_by_file = {}
16
+ @graph = {}
17
+ end
18
+
19
+ def build(*patterns)
20
+ input_files = expand_files(patterns.flatten).select { |file| processable_file?(file) }
21
+
22
+ queue = input_files.dup
23
+ processed = {}
24
+
25
+ until queue.empty?
26
+ to_process = queue.reject { |f| processed[f] }
27
+ break if to_process.empty?
28
+
29
+ to_process.each { |f| processed[f] = true }
30
+ queue.clear
31
+
32
+ # Stage 1: Parse files (parallel)
33
+ parsed = parse_files(to_process)
34
+
35
+ # Stage 2: Resolve unique constants (sequential — GVL-bound)
36
+ resolve_candidates(parsed)
37
+
38
+ # Stage 3: Build graph edges, discover new files
39
+ parsed.each do |file, groups|
40
+ deps = resolve_dependencies(file, groups)
41
+ relative_file = relativize(file)
42
+ @graph[relative_file] = deps.empty? ? EMPTY_ARRAY : deps.map { |d| relativize(d) }.freeze
43
+
44
+ deps.each do |dep|
45
+ queue << dep unless processed[dep]
46
+ end
47
+ end
48
+ end
49
+
50
+ input_files.flat_map { |file| dependencies(file) }.uniq
51
+ end
52
+
53
+ def direct_graph
54
+ @graph
55
+ end
56
+
57
+ def direct_dependencies(file)
58
+ @graph.fetch(relativize_input(file), EMPTY_ARRAY)
59
+ end
60
+
61
+ def dependencies(file)
62
+ file = relativize_input(file)
63
+ resolve_transitive_dependencies(file)
64
+ end
65
+
66
+ private
67
+
68
+ attr_reader :closure_by_file, :ignored_paths, :resolved_file_by_const, :root
69
+
70
+ def parse_files(files)
71
+ if @concurrency > 1 && files.size > 1
72
+ parse_files_parallel(files)
73
+ else
74
+ parse_files_sequential(files)
75
+ end
76
+ end
77
+
78
+ def parse_files_sequential(files)
79
+ parsed = {}
80
+ files.each { |f| parsed[f] = ReferenceExtractor.extract(f) }
81
+ parsed
82
+ end
83
+
84
+ def parse_files_parallel(files)
85
+ parsed = {}
86
+ mutex = Mutex.new
87
+ slice_size = [files.size / @concurrency + 1, 1].max
88
+
89
+ files.each_slice(slice_size).map do |slice|
90
+ Thread.new(slice) do |thread_files|
91
+ local = {}
92
+ thread_files.each { |f| local[f] = ReferenceExtractor.extract(f) }
93
+ mutex.synchronize { parsed.merge!(local) }
94
+ end
95
+ end.each(&:join)
96
+
97
+ parsed
98
+ end
99
+
100
+ def resolve_candidates(parsed)
101
+ parsed.each_value do |groups|
102
+ groups.each do |candidates|
103
+ candidates.each do |const_name|
104
+ next if resolved_file_by_const.key?(const_name)
105
+
106
+ resolve_constant_file(const_name)
107
+ end
108
+ end
109
+ end
110
+ end
111
+
112
+ def resolve_dependencies(file, groups)
113
+ deps = {}
114
+
115
+ groups.each do |candidates|
116
+ resolved_file = resolve_reference_group(candidates)
117
+ next unless resolved_file
118
+ next if resolved_file == file
119
+
120
+ deps[resolved_file] = true
121
+ end
122
+
123
+ deps.empty? ? EMPTY_ARRAY : deps.keys.sort.freeze
124
+ end
125
+
126
+ def resolve_reference_group(candidates)
127
+ candidates.each do |const_name|
128
+ file = resolved_file_by_const[const_name]
129
+ return file if file && include_path?(file)
130
+ end
131
+
132
+ nil
133
+ end
134
+
135
+ def resolve_transitive_dependencies(file)
136
+ cached = closure_by_file[file]
137
+ return cached if cached
138
+
139
+ visiting = {}
140
+ stack = [[file, :enter]]
141
+
142
+ until stack.empty?
143
+ current_file, state = stack.pop
144
+
145
+ if state == :exit
146
+ dependencies = {}
147
+
148
+ @graph.fetch(current_file, EMPTY_ARRAY).each do |dependency_file|
149
+ dependencies[dependency_file] = true
150
+
151
+ closure_by_file.fetch(dependency_file, EMPTY_ARRAY).each do |transitive_dependency|
152
+ dependencies[transitive_dependency] = true
153
+ end
154
+ end
155
+
156
+ dependencies.delete(current_file)
157
+ closure_by_file[current_file] = dependencies.empty? ? EMPTY_ARRAY : dependencies.keys.sort.freeze
158
+ visiting.delete(current_file)
159
+ next
160
+ end
161
+
162
+ next if closure_by_file.key?(current_file)
163
+ next if visiting[current_file]
164
+
165
+ visiting[current_file] = true
166
+ stack << [current_file, :exit]
167
+
168
+ @graph.fetch(current_file, EMPTY_ARRAY).reverse_each do |dependency_file|
169
+ next if closure_by_file.key?(dependency_file)
170
+ next if visiting[dependency_file]
171
+
172
+ stack << [dependency_file, :enter]
173
+ end
174
+ end
175
+
176
+ closure_by_file.fetch(file, EMPTY_ARRAY)
177
+ end
178
+
179
+ def resolve_constant_file(const_name)
180
+ return nil unless constant_defined?(const_name)
181
+
182
+ source_location = Object.const_source_location(const_name)
183
+ file = source_location&.first
184
+ return nil unless file && File.file?(file)
185
+
186
+ resolved_file_by_const[const_name] = share_path(file)
187
+ rescue StandardError
188
+ nil
189
+ end
190
+
191
+ def constant_defined?(const_name)
192
+ current = Object
193
+
194
+ const_name.split("::").each do |segment|
195
+ return false unless current.const_defined?(segment, false)
196
+
197
+ current = current.const_get(segment, false)
198
+ end
199
+
200
+ true
201
+ rescue StandardError
202
+ false
203
+ end
204
+
205
+ def include_path?(path)
206
+ return false unless FastCov::Utils.path_within?(path, root)
207
+
208
+ ignored_paths.none? { |ignored_path| FastCov::Utils.path_within?(path, ignored_path) }
209
+ end
210
+
211
+ def processable_file?(file)
212
+ File.file?(file) && include_path?(file)
213
+ end
214
+
215
+ def expand_files(patterns)
216
+ Array(patterns)
217
+ .flat_map do |pattern|
218
+ pattern = pattern.to_s
219
+ pattern = File.expand_path(pattern, root) unless File.absolute_path?(pattern)
220
+ Dir.glob(pattern)
221
+ end
222
+ .map { |path| share_path(path) }
223
+ .uniq
224
+ .sort
225
+ end
226
+
227
+ def normalize_ignored_paths(ignored_paths)
228
+ Array(ignored_paths)
229
+ .compact
230
+ .map { |path| share_path(File.expand_path(path, root)) }
231
+ .uniq
232
+ .sort
233
+ .freeze
234
+ end
235
+
236
+ def relativize(absolute_path)
237
+ share_string(absolute_path.delete_prefix(@root_prefix))
238
+ end
239
+
240
+ def relativize_input(file)
241
+ path = file.to_s
242
+ if File.absolute_path?(path)
243
+ relativize(path)
244
+ else
245
+ share_string(path)
246
+ end
247
+ end
248
+
249
+ def share_path(path)
250
+ share_string(File.expand_path(path.to_s))
251
+ end
252
+
253
+ def share_string(string)
254
+ -string.to_s
255
+ end
256
+ end
257
+ end
@@ -21,6 +21,10 @@ module FastCov
21
21
  @started_thread = nil
22
22
  end
23
23
 
24
+ def root
25
+ @coverage_map.root
26
+ end
27
+
24
28
  # Public API - called by FastCov framework
25
29
 
26
30
  def start
@@ -70,12 +74,12 @@ module FastCov
70
74
  class << self
71
75
  attr_accessor :active
72
76
 
73
- def record(to: nil)
77
+ def record(path, to: nil)
74
78
  return unless active
75
- return unless block_given?
79
+ return unless path
76
80
 
77
- path = yield
78
- active.record(path, to: to) if path
81
+ to ||= Utils.resolve_caller(caller_locations(1, 20), active.root)
82
+ active.record(path, to: to)
79
83
  end
80
84
 
81
85
  def reset
@@ -23,9 +23,8 @@ module FastCov
23
23
 
24
24
  module ConstGetPatch
25
25
  def const_get(name, inherit = true)
26
- source = caller_locations(1, 1).first&.absolute_path
27
26
  result = super
28
- FastCov::ConstGetTracker.record(to: source) { const_source_location(name, inherit)&.first }
27
+ FastCov::ConstGetTracker.record(const_source_location(name, inherit)&.first)
29
28
  result
30
29
  end
31
30
  end
@@ -42,7 +42,7 @@ module FastCov
42
42
  next unless block.is_a?(Proc)
43
43
 
44
44
  location = block.source_location
45
- record { location&.first }
45
+ record(location&.first)
46
46
  end
47
47
  end
48
48
  end
@@ -4,21 +4,27 @@ require_relative "abstract_tracker"
4
4
 
5
5
  module FastCov
6
6
  # Tracks files read from disk during coverage (JSON, YAML, .rb templates, etc.)
7
- # via File.read and File.open.
7
+ # via File.read, File.open, and YAML load methods.
8
+ #
9
+ # YAML methods are patched separately because Bootsnap's compile cache
10
+ # overrides YAML.load_file/safe_load_file to bypass File.open entirely.
8
11
  #
9
12
  # Register via: coverage_map.use(FastCov::FileTracker)
10
13
  class FileTracker < AbstractTracker
11
14
  def install
12
- return if File.singleton_class.ancestors.include?(FilePatch)
15
+ unless File.singleton_class.ancestors.include?(FilePatch)
16
+ File.singleton_class.prepend(FilePatch)
17
+ end
13
18
 
14
- File.singleton_class.prepend(FilePatch)
19
+ if defined?(::YAML) && !::YAML.singleton_class.ancestors.include?(YamlPatch)
20
+ ::YAML.singleton_class.prepend(YamlPatch)
21
+ end
15
22
  end
16
23
 
17
24
  module FilePatch
18
25
  def read(name, *args, **kwargs, &block)
19
- source = caller_locations(1, 1).first&.absolute_path
20
26
  super.tap do
21
- FastCov::FileTracker.record(to: source) { File.expand_path(name) }
27
+ FastCov::FileTracker.record(File.expand_path(name))
22
28
  end
23
29
  end
24
30
 
@@ -26,9 +32,28 @@ module FastCov
26
32
  mode = args[0]
27
33
  is_read = mode.nil? || (mode.is_a?(String) && mode.start_with?("r")) ||
28
34
  (mode.is_a?(Integer) && (mode & (File::WRONLY | File::RDWR)).zero?)
29
- source = caller_locations(1, 1).first&.absolute_path
30
35
  super.tap do
31
- FastCov::FileTracker.record(to: source) { File.expand_path(name) } if is_read
36
+ FastCov::FileTracker.record(File.expand_path(name)) if is_read
37
+ end
38
+ end
39
+ end
40
+
41
+ module YamlPatch
42
+ def load_file(path, *args, **kwargs)
43
+ super.tap do
44
+ FastCov::FileTracker.record(File.expand_path(path))
45
+ end
46
+ end
47
+
48
+ def safe_load_file(path, *args, **kwargs)
49
+ super.tap do
50
+ FastCov::FileTracker.record(File.expand_path(path))
51
+ end
52
+ end
53
+
54
+ def unsafe_load_file(path, *args, **kwargs)
55
+ super.tap do
56
+ FastCov::FileTracker.record(File.expand_path(path))
32
57
  end
33
58
  end
34
59
  end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FastCov
4
+ module Utils
5
+ # Check if path is within directory, correctly handling:
6
+ # - Trailing slashes on directory
7
+ # - Sibling directories with longer names (e.g., /a/b/c vs /a/b/cd)
8
+ def self.path_within?(path, directory)
9
+ dir = directory.end_with?("/") ? directory.chop : directory
10
+ return true if path == dir
11
+
12
+ path.start_with?("#{dir}/")
13
+ end
14
+
15
+ # Mutates set in place: converts absolute paths to relative paths from root.
16
+ # Paths not within root are left unchanged.
17
+ def self.relativize_paths(set, root)
18
+ prefix = root.end_with?("/") ? root : "#{root}/"
19
+
20
+ set.to_a.each do |abs_path|
21
+ next unless abs_path.is_a?(String)
22
+ next unless abs_path.start_with?(prefix) || abs_path == root.chomp("/")
23
+
24
+ set.delete(abs_path)
25
+ set.add(abs_path.delete_prefix(prefix))
26
+ end
27
+
28
+ set
29
+ end
30
+
31
+ # Walk caller locations to find the first frame whose file is within root.
32
+ # Handles indirect calls (e.g., YAML.load_file -> File.open) where the
33
+ # immediate caller is a stdlib/gem file outside the project.
34
+ def self.resolve_caller(locations, root)
35
+ locations.each do |loc|
36
+ path = loc.absolute_path
37
+ next unless path
38
+
39
+ return path if path_within?(path, root)
40
+ end
41
+ nil
42
+ end
43
+ end
44
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FastCov
4
- VERSION = "0.2.1"
4
+ VERSION = "0.3.1"
5
5
  end
data/lib/fast_cov.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require "fast_cov/fast_cov.#{RUBY_VERSION}"
4
4
 
5
5
  module FastCov
6
+ autoload :Utils, File.expand_path("fast_cov/utils", __dir__)
6
7
  autoload :VERSION, File.expand_path("fast_cov/version", __dir__)
7
8
  autoload :ConnectedDependencies, File.expand_path("fast_cov/connected_dependencies", __dir__)
8
9
  autoload :CoverageMap, File.expand_path("fast_cov/coverage_map", __dir__)
@@ -10,4 +11,5 @@ module FastCov
10
11
  autoload :FileTracker, File.expand_path("fast_cov/trackers/file_tracker", __dir__)
11
12
  autoload :FactoryBotTracker, File.expand_path("fast_cov/trackers/factory_bot_tracker", __dir__)
12
13
  autoload :ConstGetTracker, File.expand_path("fast_cov/trackers/const_get_tracker", __dir__)
14
+ autoload :StaticMap, File.expand_path("fast_cov/static_map", __dir__)
13
15
  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.2.1
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ngan Pham
@@ -85,10 +85,13 @@ files:
85
85
  - lib/fast_cov/connected_dependencies.rb
86
86
  - lib/fast_cov/coverage_map.rb
87
87
  - lib/fast_cov/dev.rb
88
+ - lib/fast_cov/static_map.rb
89
+ - lib/fast_cov/static_map/reference_extractor.rb
88
90
  - lib/fast_cov/trackers/abstract_tracker.rb
89
91
  - lib/fast_cov/trackers/const_get_tracker.rb
90
92
  - lib/fast_cov/trackers/factory_bot_tracker.rb
91
93
  - lib/fast_cov/trackers/file_tracker.rb
94
+ - lib/fast_cov/utils.rb
92
95
  - lib/fast_cov/version.rb
93
96
  homepage: https://github.com/Gusto/fast_cov
94
97
  licenses: