fast_cov 0.1.6 → 0.2.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.
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FastCov
4
+ class ConnectedDependencies
5
+ def initialize
6
+ @connections = {}
7
+ @mutex = Mutex.new
8
+ end
9
+
10
+ def connect(from:, to:)
11
+ @mutex.synchronize do
12
+ (@connections[from] ||= {})[to] = true
13
+ end
14
+ end
15
+
16
+ def expand(paths)
17
+ raise ArgumentError, "paths must be a Set" unless paths.is_a?(Set)
18
+
19
+ pending_paths = paths.to_a
20
+
21
+ until pending_paths.empty?
22
+ path = pending_paths.pop
23
+
24
+ connections = @connections[path]
25
+ next unless connections
26
+
27
+ connections.each_key do |dependency|
28
+ next unless paths.add?(dependency)
29
+
30
+ pending_paths << dependency
31
+ end
32
+ end
33
+
34
+ paths
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module FastCov
6
+ class CoverageMap
7
+ class ConfigurationError < StandardError; end
8
+
9
+ attr_accessor :threads
10
+ attr_reader :ignored_paths
11
+ attr_reader :root
12
+ attr_reader :connected_dependencies
13
+
14
+ def initialize
15
+ @root = Dir.pwd
16
+ @threads = true
17
+ @ignored_paths = []
18
+ @connected_dependencies = ConnectedDependencies.new
19
+ @trackers = []
20
+ @native_coverage = nil
21
+ @started = false
22
+ end
23
+
24
+ def root=(value)
25
+ return @root = nil if value.nil?
26
+
27
+ path = value.to_s
28
+ unless absolute_path?(path)
29
+ raise ConfigurationError, "root must be an absolute path, got: #{path.inspect}"
30
+ end
31
+
32
+ @root = path
33
+ end
34
+
35
+ def ignored_paths=(value)
36
+ @ignored_paths =
37
+ case value
38
+ when nil
39
+ []
40
+ when Array
41
+ value.dup
42
+ else
43
+ [value]
44
+ end
45
+ end
46
+
47
+ def use(tracker_class, **options)
48
+ raise "CoverageMap is already started" if @started
49
+ raise ArgumentError, "#{tracker_class} is already registered" if @trackers.any? { |tracker| tracker.is_a?(tracker_class) }
50
+
51
+ tracker = tracker_class.new(self, **options)
52
+ tracker.install if tracker.respond_to?(:install)
53
+ @trackers << tracker
54
+ self
55
+ end
56
+
57
+ def start
58
+ if @started
59
+ raise "CoverageMap is already started" if block_given?
60
+ return self
61
+ end
62
+
63
+ begin
64
+ @native_coverage = Coverage.new(
65
+ root: normalized_root,
66
+ ignored_paths: normalized_ignored_paths,
67
+ threads: @threads != false
68
+ )
69
+ @native_coverage.start
70
+ @trackers.each(&:start)
71
+ @started = true
72
+ rescue StandardError
73
+ cleanup_failed_start
74
+ raise
75
+ end
76
+
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
88
+ end
89
+
90
+ def stop
91
+ return Set.new unless @started
92
+
93
+ result = Set.new(@native_coverage.stop.each_key)
94
+ @trackers.reverse_each { |tracker| result.merge(tracker.stop) }
95
+ @connected_dependencies.expand(result)
96
+ Utils.relativize_paths(result, normalized_root)
97
+ ensure
98
+ @native_coverage = nil
99
+ @started = false
100
+ end
101
+
102
+ def include_path?(path)
103
+ return false unless path
104
+ return false unless Utils.path_within?(path, normalized_root)
105
+ return true if @ignored_paths.empty?
106
+
107
+ normalized_ignored_paths.none? { |ignored_path| Utils.path_within?(path, ignored_path) }
108
+ end
109
+
110
+ def connect(from:, to:)
111
+ source = normalize_path(from)
112
+ target = normalize_path(to)
113
+ return unless include_path?(source)
114
+ return unless include_path?(target)
115
+ return if source == target
116
+
117
+ @connected_dependencies.connect(from: source, to: target)
118
+ end
119
+
120
+ private
121
+
122
+ def normalized_root
123
+ path = @root&.to_s
124
+ raise ConfigurationError, "root is required" if path.nil? || path.empty?
125
+ raise ConfigurationError, "root must be an absolute path, got: #{path.inspect}" unless absolute_path?(path)
126
+
127
+ path
128
+ end
129
+
130
+ def normalized_ignored_paths
131
+ root = normalized_root
132
+
133
+ @ignored_paths.map do |value|
134
+ path = value.to_s
135
+ path = File.join(root, path) unless absolute_path?(path)
136
+
137
+ unless Utils.path_within?(path, root)
138
+ raise ConfigurationError,
139
+ "ignored_paths must be inside root (#{root.inspect}), got: #{path.inspect}"
140
+ end
141
+
142
+ path
143
+ end
144
+ end
145
+
146
+ def absolute_path?(path)
147
+ Pathname.new(path).absolute?
148
+ end
149
+
150
+ def normalize_path(path)
151
+ return if path.nil?
152
+
153
+ File.expand_path(path.to_s)
154
+ end
155
+
156
+ def cleanup_failed_start
157
+ @trackers.reverse_each do |tracker|
158
+ begin
159
+ tracker.stop
160
+ rescue StandardError
161
+ nil
162
+ end
163
+ end
164
+
165
+ begin
166
+ @native_coverage&.stop
167
+ rescue StandardError
168
+ nil
169
+ end
170
+
171
+ @native_coverage = nil
172
+ @started = false
173
+ end
174
+ end
175
+ end
@@ -4,8 +4,8 @@ module FastCov
4
4
  # Base class for trackers that record file paths during coverage.
5
5
  #
6
6
  # Provides common functionality:
7
- # - Path filtering (root, ignored_path)
8
- # - Thread-aware recording (threads option)
7
+ # - Path filtering (root, ignored_paths)
8
+ # - Thread-aware recording (CoverageMap#threads)
9
9
  # - File collection and lifecycle management
10
10
  #
11
11
  # Descendants override hooks: on_start, on_stop, on_record
@@ -15,10 +15,8 @@ module FastCov
15
15
  # - threads: true -> record from ALL threads (global tracking)
16
16
  # - threads: false -> only record from the thread that called start
17
17
  class AbstractTracker
18
- def initialize(config, **options)
19
- @root = options.fetch(:root, config.root)
20
- @ignored_path = options.fetch(:ignored_path, config.ignored_path)
21
- @threads = options.fetch(:threads, config.threads)
18
+ def initialize(coverage_map, **_options)
19
+ @coverage_map = coverage_map
22
20
  @files = nil
23
21
  @started_thread = nil
24
22
  end
@@ -27,7 +25,7 @@ module FastCov
27
25
 
28
26
  def start
29
27
  @files = Set.new
30
- @started_thread = Thread.current unless @threads
28
+ @started_thread = Thread.current unless @coverage_map.threads
31
29
  self.class.active = self
32
30
  on_start
33
31
  end
@@ -41,11 +39,14 @@ module FastCov
41
39
  result
42
40
  end
43
41
 
44
- def record(abs_path)
45
- return if !@threads && Thread.current != @started_thread
46
- return unless Utils.path_within?(abs_path, @root)
47
- return if @ignored_path && Utils.path_within?(abs_path, @ignored_path)
48
- @files.add(abs_path) if on_record(abs_path)
42
+ def record(abs_path, to: nil)
43
+ return if !@coverage_map.threads && Thread.current != @started_thread
44
+
45
+ path = normalize_path(abs_path)
46
+ return unless @coverage_map.include_path?(path)
47
+
48
+ @coverage_map.connect(from: to, to: path) if to
49
+ @files.add(path) if on_record(path)
49
50
  end
50
51
 
51
52
  # Hooks for descendants - override as needed
@@ -54,26 +55,27 @@ module FastCov
54
55
  def on_start; end
55
56
  def on_stop; end
56
57
 
57
- def on_record(abs_path)
58
+ def on_record(path)
58
59
  true
59
60
  end
60
61
 
62
+ private
63
+
64
+ def normalize_path(path)
65
+ return if path.nil?
66
+
67
+ File.expand_path(path.to_s)
68
+ end
69
+
61
70
  class << self
62
71
  attr_accessor :active
63
72
 
64
- # Record a file path. Accepts a path directly or a block that returns the path.
65
- # If block given, it's only executed when tracker is active (avoids expensive work).
66
- # Nil values are ignored.
67
- #
68
- # record("/path/to/file.rb") # direct path
69
- # record { expensive_lookup } # lazy evaluation
70
- # record("/path") { fallback } # path takes precedence
71
- #
72
- def record(abs_path = nil)
73
+ def record(to: nil)
73
74
  return unless active
75
+ return unless block_given?
74
76
 
75
- path = abs_path || (yield if block_given?)
76
- active.record(path) if path
77
+ path = yield
78
+ active.record(path, to: to) if path
77
79
  end
78
80
 
79
81
  def reset
@@ -12,22 +12,20 @@ module FastCov
12
12
  #
13
13
  # Note: This does NOT catch direct constant references like `Foo::Bar` in source
14
14
  # code - those compile to opt_getconstant_path bytecode and bypass const_get.
15
- # Use CoverageTracker with constant_references: true for static analysis.
16
15
  #
17
- # Register via: config.use FastCov::ConstGetTracker
18
- # Options: root, ignored_path, threads (all default from config)
16
+ # Register via: coverage_map.use(FastCov::ConstGetTracker)
19
17
  class ConstGetTracker < AbstractTracker
20
18
  def install
19
+ return if Module.ancestors.include?(ConstGetPatch)
20
+
21
21
  Module.prepend(ConstGetPatch)
22
22
  end
23
23
 
24
24
  module ConstGetPatch
25
25
  def const_get(name, inherit = true)
26
+ source = caller_locations(1, 1).first&.absolute_path
26
27
  result = super
27
- FastCov::ConstGetTracker.record do
28
- location = self.const_source_location(name, inherit) rescue nil
29
- location&.first
30
- end
28
+ FastCov::ConstGetTracker.record(to: source) { const_source_location(name, inherit)&.first }
31
29
  result
32
30
  end
33
31
  end
@@ -9,14 +9,15 @@ module FastCov
9
9
  # This tracker intercepts FactoryBot.factories.find (called by create/build)
10
10
  # to record the source file where each factory was defined.
11
11
  #
12
- # Register via: config.use FastCov::FactoryBotTracker
13
- # Options: root, ignored_path, threads (all default from config)
12
+ # Register via: coverage_map.use(FastCov::FactoryBotTracker)
14
13
  class FactoryBotTracker < AbstractTracker
15
14
  def install
16
15
  unless defined?(::FactoryBot)
17
16
  raise LoadError, "FactoryBotTracker requires the factory_bot gem to be installed"
18
17
  end
19
18
 
19
+ return if ::FactoryBot.factories.singleton_class.ancestors.include?(RegistryPatch)
20
+
20
21
  ::FactoryBot.factories.singleton_class.prepend(RegistryPatch)
21
22
  end
22
23
 
@@ -41,9 +42,7 @@ module FastCov
41
42
  next unless block.is_a?(Proc)
42
43
 
43
44
  location = block.source_location
44
- next unless location
45
-
46
- record(location[0])
45
+ record { location&.first }
47
46
  end
48
47
  end
49
48
  end
@@ -6,23 +6,30 @@ module FastCov
6
6
  # Tracks files read from disk during coverage (JSON, YAML, .rb templates, etc.)
7
7
  # via File.read and File.open.
8
8
  #
9
- # Register via: config.use FastCov::FileTracker
10
- # Options: root, ignored_path, threads (all default from config)
9
+ # Register via: coverage_map.use(FastCov::FileTracker)
11
10
  class FileTracker < AbstractTracker
12
11
  def install
12
+ return if File.singleton_class.ancestors.include?(FilePatch)
13
+
13
14
  File.singleton_class.prepend(FilePatch)
14
15
  end
15
16
 
16
17
  module FilePatch
17
18
  def read(name, *args, **kwargs, &block)
18
- super.tap { FastCov::FileTracker.record { File.expand_path(name) } }
19
+ source = caller_locations(1, 1).first&.absolute_path
20
+ super.tap do
21
+ FastCov::FileTracker.record(to: source) { File.expand_path(name) }
22
+ end
19
23
  end
20
24
 
21
25
  def open(name, *args, **kwargs, &block)
22
26
  mode = args[0]
23
27
  is_read = mode.nil? || (mode.is_a?(String) && mode.start_with?("r")) ||
24
28
  (mode.is_a?(Integer) && (mode & (File::WRONLY | File::RDWR)).zero?)
25
- super.tap { FastCov::FileTracker.record { File.expand_path(name) } if is_read }
29
+ source = caller_locations(1, 1).first&.absolute_path
30
+ super.tap do
31
+ FastCov::FileTracker.record(to: source) { File.expand_path(name) } if is_read
32
+ end
26
33
  end
27
34
  end
28
35
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FastCov
4
- VERSION = "0.1.6"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/fast_cov.rb CHANGED
@@ -3,14 +3,11 @@
3
3
  require "fast_cov/fast_cov.#{RUBY_VERSION}"
4
4
 
5
5
  module FastCov
6
- autoload :VERSION, "fast_cov/version"
7
- autoload :Configuration, "fast_cov/configuration"
8
- autoload :Singleton, "fast_cov/singleton"
9
- autoload :AbstractTracker, "fast_cov/trackers/abstract_tracker"
10
- autoload :CoverageTracker, "fast_cov/trackers/coverage_tracker"
11
- autoload :FileTracker, "fast_cov/trackers/file_tracker"
12
- autoload :FactoryBotTracker, "fast_cov/trackers/factory_bot_tracker"
13
- autoload :ConstGetTracker, "fast_cov/trackers/const_get_tracker"
14
-
15
- extend Singleton
6
+ autoload :VERSION, File.expand_path("fast_cov/version", __dir__)
7
+ autoload :ConnectedDependencies, File.expand_path("fast_cov/connected_dependencies", __dir__)
8
+ autoload :CoverageMap, File.expand_path("fast_cov/coverage_map", __dir__)
9
+ autoload :AbstractTracker, File.expand_path("fast_cov/trackers/abstract_tracker", __dir__)
10
+ autoload :FileTracker, File.expand_path("fast_cov/trackers/file_tracker", __dir__)
11
+ autoload :FactoryBotTracker, File.expand_path("fast_cov/trackers/factory_bot_tracker", __dir__)
12
+ autoload :ConstGetTracker, File.expand_path("fast_cov/trackers/const_get_tracker", __dir__)
16
13
  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.1.6
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ngan Pham
@@ -9,20 +9,6 @@ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
- - !ruby/object:Gem::Dependency
13
- name: prism
14
- requirement: !ruby/object:Gem::Requirement
15
- requirements:
16
- - - ">="
17
- - !ruby/object:Gem::Version
18
- version: '1.0'
19
- type: :runtime
20
- prerelease: false
21
- version_requirements: !ruby/object:Gem::Requirement
22
- requirements:
23
- - - ">="
24
- - !ruby/object:Gem::Version
25
- version: '1.0'
26
12
  - !ruby/object:Gem::Dependency
27
13
  name: rake
28
14
  requirement: !ruby/object:Gem::Requirement
@@ -96,13 +82,11 @@ files:
96
82
  - lib/fast_cov/benchmark/runner.rb
97
83
  - lib/fast_cov/benchmark/scenarios.rb
98
84
  - lib/fast_cov/compiler.rb
99
- - lib/fast_cov/configuration.rb
100
- - lib/fast_cov/constant_extractor.rb
85
+ - lib/fast_cov/connected_dependencies.rb
86
+ - lib/fast_cov/coverage_map.rb
101
87
  - lib/fast_cov/dev.rb
102
- - lib/fast_cov/singleton.rb
103
88
  - lib/fast_cov/trackers/abstract_tracker.rb
104
89
  - lib/fast_cov/trackers/const_get_tracker.rb
105
- - lib/fast_cov/trackers/coverage_tracker.rb
106
90
  - lib/fast_cov/trackers/factory_bot_tracker.rb
107
91
  - lib/fast_cov/trackers/file_tracker.rb
108
92
  - lib/fast_cov/version.rb
@@ -1,71 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "pathname"
4
-
5
- module FastCov
6
- class Configuration
7
- class ConfigurationError < StandardError; end
8
-
9
- attr_accessor :threads
10
- attr_reader :root, :ignored_path
11
-
12
- def initialize
13
- @root = Dir.pwd
14
- @ignored_path = nil
15
- @threads = true
16
- @tracker_definitions = []
17
- end
18
-
19
- def root=(value)
20
- return @root = nil if value.nil?
21
-
22
- path = value.to_s
23
- unless absolute_path?(path)
24
- raise ConfigurationError, "root must be an absolute path, got: #{path.inspect}"
25
- end
26
- @root = path
27
- end
28
-
29
- def ignored_path=(value)
30
- return @ignored_path = nil if value.nil?
31
-
32
- path = value.to_s
33
-
34
- # Expand relative paths against root
35
- unless absolute_path?(path)
36
- path = File.join(@root, path)
37
- end
38
-
39
- # Validate ignored_path is inside root
40
- unless path.start_with?(@root)
41
- raise ConfigurationError,
42
- "ignored_path must be inside root (#{@root.inspect}), got: #{path.inspect}"
43
- end
44
-
45
- @ignored_path = path
46
- end
47
-
48
- def use(tracker_class, **options)
49
- @tracker_definitions << {klass: tracker_class, options: options}
50
- end
51
-
52
- def install_trackers
53
- @tracker_definitions.map do |entry|
54
- tracker = entry[:klass].new(self, **entry[:options])
55
- tracker.install if tracker.respond_to?(:install)
56
- tracker
57
- end
58
- end
59
-
60
- def reset
61
- initialize
62
- self
63
- end
64
-
65
- private
66
-
67
- def absolute_path?(path)
68
- Pathname.new(path).absolute?
69
- end
70
- end
71
- end
@@ -1,53 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "prism"
4
-
5
- module FastCov
6
- module ConstantExtractor
7
- def self.extract(filename)
8
- result = Prism.parse_file(filename)
9
- constants = []
10
- collect_constants(result.value, constants)
11
- constants
12
- end
13
-
14
- class << self
15
- private
16
-
17
- def collect_constants(node, constants)
18
- case node
19
- when Prism::ConstantPathNode
20
- path = resolve_constant_path(node)
21
- if path
22
- constants << path
23
- return
24
- end
25
- # Dynamic parent (e.g., expr::Foo) — fall through to walk children
26
- when Prism::ConstantReadNode
27
- constants << node.name.to_s
28
- return
29
- end
30
-
31
- node.compact_child_nodes.each { |child| collect_constants(child, constants) }
32
- end
33
-
34
- def resolve_constant_path(node)
35
- parts = []
36
- current = node
37
-
38
- while current.is_a?(Prism::ConstantPathNode)
39
- parts.unshift(current.name.to_s)
40
- current = current.parent
41
- end
42
-
43
- if current.is_a?(Prism::ConstantReadNode)
44
- parts.unshift(current.name.to_s)
45
- elsif !current.nil?
46
- return nil
47
- end
48
-
49
- parts.join("::")
50
- end
51
- end
52
- end
53
- end
@@ -1,44 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module FastCov
4
- module Singleton
5
- def configured?
6
- !@trackers.nil? && !@trackers.empty?
7
- end
8
-
9
- def configure
10
- @configuration = Configuration.new
11
- yield(@configuration)
12
- @trackers = @configuration.install_trackers
13
- self
14
- end
15
-
16
- def start(&block)
17
- raise "FastCov.configure must be called before start" unless configured?
18
- @trackers.each(&:start)
19
- if block
20
- result = nil
21
- begin
22
- yield
23
- ensure
24
- result = stop
25
- end
26
- result
27
- else
28
- self
29
- end
30
- end
31
-
32
- def stop
33
- raise "FastCov.configure must be called before stop" unless configured?
34
- result = Set.new
35
- @trackers.each { |t| result.merge(t.stop) }
36
- Utils.relativize_paths(result, @configuration.root)
37
- end
38
-
39
- def reset
40
- @trackers = nil
41
- @configuration = nil
42
- end
43
- end
44
- end
@@ -1,28 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module FastCov
4
- # Wraps the FastCov::Coverage C extension as a tracker plugin.
5
- # Handles line coverage, allocation tracing, and constant resolution.
6
- #
7
- # Register via: config.use FastCov::CoverageTracker
8
- # Options: root, ignored_path, threads, constant_references, allocations
9
- class CoverageTracker
10
- def initialize(config, **options)
11
- @coverage = Coverage.new(
12
- root: options.fetch(:root, config.root),
13
- ignored_path: options.fetch(:ignored_path, config.ignored_path),
14
- threads: options.fetch(:threads, config.threads),
15
- constant_references: options.fetch(:constant_references, true),
16
- allocations: options.fetch(:allocations, true)
17
- )
18
- end
19
-
20
- def start
21
- @coverage.start
22
- end
23
-
24
- def stop
25
- Set.new(@coverage.stop.each_key)
26
- end
27
- end
28
- end