fast_cov 0.2.0 → 0.3.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: c1319e05ab0d7a740626e94ed880dacb09b980dfc0ededc6b1c0259383fb0667
4
- data.tar.gz: dd7af260d917264ab2f30affbc3dd2dfa44f5ddba03bc8d1f8dd222e29a9817e
3
+ metadata.gz: 4a8e169598d457f54b52e872c65d55a9e87164bcac66654d7b21d9497aeba43a
4
+ data.tar.gz: 678150e27c7fb664bc5786764b063c1abb75218048f226b39495bc97c012ce75
5
5
  SHA512:
6
- metadata.gz: 6b594da4510ba6bb43767030b4d25e3f7787e22923b6e76b48616f2a085c030d22c1d870976570f7ac74fe921fe2c1eabbeeb16471ec8a533588f9d5b9974fef
7
- data.tar.gz: 98f1b25e2565a56af55e472096a4c3159110b27117b99682cd031888083ec68d5aef8788aeff7a2538af9a5287f588653f8717ee30160e1126f127a0316191ea
6
+ metadata.gz: 68e671eab2e3583313b60e408f179006d77aa0d571d1b258b971916b38642fcedae05b8b728ff0588000bd69ada0183a3502b8f6b4e90a37fda3847be3dd6fcd
7
+ data.tar.gz: d4531b99a2ca2cfb1a566f63bed51444a91bedfd0356a33d50101f18aafa0020ed40d652199de538cb351c6ac2e5e6a7185c1a3aaef316bc444779767a19ad23
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
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "pathname"
4
-
5
3
  module FastCov
6
4
  class CoverageMap
7
5
  class ConfigurationError < StandardError; end
@@ -54,11 +52,21 @@ module FastCov
54
52
  self
55
53
  end
56
54
 
57
- def start
58
- if @started
59
- raise "CoverageMap is already started" if block_given?
60
- 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
61
64
  end
65
+ result
66
+ end
67
+
68
+ def start
69
+ return self if @started
62
70
 
63
71
  begin
64
72
  @native_coverage = Coverage.new(
@@ -74,17 +82,7 @@ module FastCov
74
82
  raise
75
83
  end
76
84
 
77
- if block_given?
78
- result = nil
79
- begin
80
- yield
81
- ensure
82
- result = stop
83
- end
84
- result
85
- else
86
- self
87
- end
85
+ self
88
86
  end
89
87
 
90
88
  def stop
@@ -144,7 +142,7 @@ module FastCov
144
142
  end
145
143
 
146
144
  def absolute_path?(path)
147
- Pathname.new(path).absolute?
145
+ File.absolute_path?(path)
148
146
  end
149
147
 
150
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FastCov
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/fast_cov.rb CHANGED
@@ -10,4 +10,5 @@ module FastCov
10
10
  autoload :FileTracker, File.expand_path("fast_cov/trackers/file_tracker", __dir__)
11
11
  autoload :FactoryBotTracker, File.expand_path("fast_cov/trackers/factory_bot_tracker", __dir__)
12
12
  autoload :ConstGetTracker, File.expand_path("fast_cov/trackers/const_get_tracker", __dir__)
13
+ autoload :StaticMap, File.expand_path("fast_cov/static_map", __dir__)
13
14
  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.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ngan Pham
@@ -85,6 +85,8 @@ 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