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 +4 -4
- data/README.md +43 -1
- data/ext/fast_cov/fast_cov.c +0 -78
- data/lib/fast_cov/coverage_map.rb +16 -16
- data/lib/fast_cov/static_map/reference_extractor.rb +156 -0
- data/lib/fast_cov/static_map.rb +257 -0
- data/lib/fast_cov/trackers/abstract_tracker.rb +8 -4
- data/lib/fast_cov/trackers/const_get_tracker.rb +1 -2
- data/lib/fast_cov/trackers/factory_bot_tracker.rb +1 -1
- data/lib/fast_cov/trackers/file_tracker.rb +32 -7
- data/lib/fast_cov/utils.rb +44 -0
- data/lib/fast_cov/version.rb +1 -1
- data/lib/fast_cov.rb +2 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ba4073b332a0c7f0837d5cf954b9ad98353721e6350c24969541819a42a27c65
|
|
4
|
+
data.tar.gz: c85de804a17e0f1bd7b21a370e61dbe0d59dc2bb5ce02d84f827c500d6816b5a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
212
|
+
MyTracker.record { some_file_path }
|
|
171
213
|
super
|
|
172
214
|
end
|
|
173
215
|
end
|
data/ext/fast_cov/fast_cov.c
CHANGED
|
@@ -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
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
79
|
+
return unless path
|
|
76
80
|
|
|
77
|
-
|
|
78
|
-
active.record(path, to: to)
|
|
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(
|
|
27
|
+
FastCov::ConstGetTracker.record(const_source_location(name, inherit)&.first)
|
|
29
28
|
result
|
|
30
29
|
end
|
|
31
30
|
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
|
|
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
|
-
|
|
15
|
+
unless File.singleton_class.ancestors.include?(FilePatch)
|
|
16
|
+
File.singleton_class.prepend(FilePatch)
|
|
17
|
+
end
|
|
13
18
|
|
|
14
|
-
|
|
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(
|
|
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(
|
|
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
|
data/lib/fast_cov/version.rb
CHANGED
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.
|
|
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:
|