rspec-covers 0.1.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.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +202 -0
  4. data/data/evaluation/ground_truth.example.json +26 -0
  5. data/lib/minitest/covers.rb +133 -0
  6. data/lib/rspec/covers/checked_coverage.rb +27 -0
  7. data/lib/rspec/covers/code_location.rb +49 -0
  8. data/lib/rspec/covers/configuration.rb +135 -0
  9. data/lib/rspec/covers/declaration.rb +199 -0
  10. data/lib/rspec/covers/declaration_validation.rb +65 -0
  11. data/lib/rspec/covers/evaluation.rb +197 -0
  12. data/lib/rspec/covers/formatter.rb +70 -0
  13. data/lib/rspec/covers/integration.rb +54 -0
  14. data/lib/rspec/covers/metadata_reader.rb +34 -0
  15. data/lib/rspec/covers/method_entry.rb +19 -0
  16. data/lib/rspec/covers/method_label.rb +37 -0
  17. data/lib/rspec/covers/probe/call_log_probe.rb +132 -0
  18. data/lib/rspec/covers/probe/coverage_probe.rb +57 -0
  19. data/lib/rspec/covers/production_inventory.rb +90 -0
  20. data/lib/rspec/covers/rake_task.rb +63 -0
  21. data/lib/rspec/covers/reporter.rb +162 -0
  22. data/lib/rspec/covers/source_range.rb +37 -0
  23. data/lib/rspec/covers/static_method_scanner.rb +184 -0
  24. data/lib/rspec/covers/strict_verdict.rb +107 -0
  25. data/lib/rspec/covers/tracer/composite.rb +73 -0
  26. data/lib/rspec/covers/tracer/dynamic.rb +27 -0
  27. data/lib/rspec/covers/tracer/dynamic_corpus.rb +43 -0
  28. data/lib/rspec/covers/tracer/lcba.rb +17 -0
  29. data/lib/rspec/covers/tracer/nc.rb +36 -0
  30. data/lib/rspec/covers/tracer/ncc.rb +23 -0
  31. data/lib/rspec/covers/tracer/suggestion.rb +17 -0
  32. data/lib/rspec/covers/tracer/tokenizer.rb +18 -0
  33. data/lib/rspec/covers/version.rb +11 -0
  34. data/lib/rspec/covers.rb +225 -0
  35. data/sig/minitest/covers.rbs +6 -0
  36. data/sig/rspec/covers.rbs +27 -0
  37. metadata +134 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f8c722d38cbc69d92194560b7f3fa27b7044042867b9f2fda68a1922a59d00ad
4
+ data.tar.gz: 1c0e8fba14560d37903695f4f13c970cca371cadc05288d51c84390b40f64582
5
+ SHA512:
6
+ metadata.gz: 27469f2a735415ff24ad8c21b9ff6517cf4a74a7c7eb948926122dfe7717c5b9e58161e5966e5ab414134d3f12c1067826ac3cfc50e99eb426f44c545bed58ab
7
+ data.tar.gz: 6f956835839bcb164de4695dde6322adff74db05c5ba80a0dd66229fbe7ee80a520472fb3d7deedb8521529f50dcecee42838ca025abbc7a9b03a8b6d7a9904a
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yudai Takada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,202 @@
1
+ # rspec-covers
2
+
3
+ `rspec-covers` adds coverage intent metadata to RSpec examples.
4
+
5
+ It lets a spec declare the production code it is responsible for with `covers`,
6
+ declare allowed dependencies with `uses`, and optionally fail examples that
7
+ execute production code outside that declaration.
8
+
9
+ ## Installation
10
+
11
+ Add the gem to your application's Gemfile:
12
+
13
+ ```ruby
14
+ gem "rspec-covers"
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ Configure the integration from `spec_helper.rb`:
20
+
21
+ ```ruby
22
+ require "rspec/covers"
23
+
24
+ RSpec.configure do |config|
25
+ RSpec::Covers.configure(config) do |covers|
26
+ covers.strict = true
27
+ covers.risky = :fail
28
+ covers.mode = :executed
29
+ covers.undeclared = :warn
30
+ covers.production_paths = %w[app lib]
31
+ covers.allowlist = %w[app/models/application_record.rb lib/instrumentation/**]
32
+ covers.suggest = true
33
+ covers.validate_declarations = true
34
+ covers.report_path = ".rspec-covers/result.json"
35
+ end
36
+ end
37
+ ```
38
+
39
+ Declare intent on example groups or examples:
40
+
41
+ ```ruby
42
+ RSpec.describe UserCreator, covers: UserCreator do
43
+ it "creates a user", covers: "UserCreator#call" do
44
+ expect(UserCreator.new(params).call).to be_persisted
45
+ end
46
+
47
+ it "notifies", covers: "UserCreator#call", uses: [Notifier, "User.create!"] do
48
+ expect(UserCreator.new(params).call).to be_success
49
+ end
50
+ end
51
+ ```
52
+
53
+ `covers` and `uses` accept:
54
+
55
+ - constants such as `UserCreator`
56
+ - method strings such as `"UserCreator#call"` or `"User.create!"`
57
+ - file paths or globs such as `"app/services/**/*.rb"`
58
+ - regular expressions matched against production method labels and paths
59
+
60
+ ## Strict Coverage
61
+
62
+ For each example, `rspec-covers` records per-example line coverage with
63
+ `Coverage.peek_result`.
64
+
65
+ When `strict` is enabled, an example is risky if it executes production lines
66
+ that are not covered by its `covers` or `uses` declaration. Risky examples raise
67
+ `RSpec::Covers::StrictCoverageError` by default.
68
+
69
+ Set `covers.risky = :report` to keep the RSpec example green while still
70
+ recording the example as risky in formatter output and JSON reports.
71
+
72
+ Examples with no declaration follow `covers.undeclared`:
73
+
74
+ - `:ignore` ignores them
75
+ - `:warn` prints a warning
76
+ - `:risky` fails when production code is executed
77
+
78
+ ## Suggestions
79
+
80
+ With `covers.suggest = true`, undeclared examples receive ranked `covers`
81
+ suggestions. The scorer combines:
82
+
83
+ - `NC`: spec file or described class naming convention
84
+ - `NCC`: token overlap between example text and method labels
85
+ - `LCBA`: the production call immediately before `expect(...)`
86
+ - `dynamic`: suite-wide tf-idf over production calls
87
+
88
+ Run:
89
+
90
+ ```bash
91
+ rake covers:suggest[.rspec-covers/result.json]
92
+ ```
93
+
94
+ With `covers.validate_declarations = true`, examples that already declare
95
+ `covers` are also scored. Weakly supported declarations are written to
96
+ `declaration_validation` in the JSON report and shown by the formatter.
97
+
98
+ ## Checked Mode
99
+
100
+ `covers.mode = :checked` narrows executed coverage to production methods that
101
+ contributed to expectations. It tracks production method return values passed to
102
+ `expect(...)` and the last production call before each expectation. This is an
103
+ approximation of checked coverage, not a full dynamic slice.
104
+
105
+ In checked mode, the JSON report includes `unchecked_locations`: production
106
+ lines that ran during the example but were not attributed to expectation
107
+ inputs.
108
+
109
+ ## Rake Tasks
110
+
111
+ ```bash
112
+ rake covers:report[.rspec-covers/result.json]
113
+ rake covers:suggest[.rspec-covers/result.json]
114
+ rake covers:evaluate[ground_truth.json,predictions.json]
115
+ ```
116
+
117
+ The JSON report also includes `orphan_methods`: production methods found from
118
+ loaded constants and static Ripper scans of `production_paths` that were not
119
+ named by any example's `covers` declaration.
120
+
121
+ `covers:evaluate` reports traceability precision, recall, F1, and
122
+ applicability for suggestions against a JSON ground-truth file. If the ground
123
+ truth examples include `risky`, `seeded`, or `checked_locations`, it also
124
+ reports strict coverage, seeded strict recall, and checked coverage metrics.
125
+ Targets can be declared in the ground truth under `targets` and are reported as
126
+ pass/fail comparisons.
127
+
128
+ Ground truth can be a simple mapping:
129
+
130
+ ```json
131
+ {
132
+ "examples": {
133
+ "spec/example_spec.rb[1:1]": ["UserCreator#call"]
134
+ }
135
+ }
136
+ ```
137
+
138
+ Or an array with strict and checked labels:
139
+
140
+ ```json
141
+ {
142
+ "targets": {
143
+ "traceability.f1": 0.75,
144
+ "strict.recall": 0.90,
145
+ "checked.f1": 0.70
146
+ },
147
+ "examples": [
148
+ {
149
+ "id": "spec/example_spec.rb[1:1]",
150
+ "covers": ["UserCreator#call"],
151
+ "risky": true,
152
+ "seeded": true,
153
+ "checked_locations": [{ "file": "app/user_creator.rb", "line": 12 }]
154
+ }
155
+ ]
156
+ }
157
+ ```
158
+
159
+ Enable JSON formatter events with:
160
+
161
+ ```ruby
162
+ covers.json_events = true
163
+ ```
164
+
165
+ When `RSpec::Covers::Formatter` is active, it emits JSON Lines with
166
+ `type: "rspec_covers.example"` for each recorded example.
167
+
168
+ ## Minitest
169
+
170
+ The core coverage engine can also be attached to Minitest:
171
+
172
+ ```ruby
173
+ require "minitest/covers"
174
+
175
+ Minitest::Covers.configure do |covers|
176
+ covers.strict = true
177
+ end
178
+
179
+ class UserCreatorTest < Minitest::Test
180
+ covers "UserCreator#call"
181
+ uses "Notifier.deliver", target: :test_creates_user
182
+
183
+ def test_creates_user
184
+ assert UserCreator.new(params).call
185
+ end
186
+ end
187
+ ```
188
+
189
+ Class-level `covers` / `uses` apply to all tests in the class. Pass
190
+ `target: :test_name` for method-level Minitest metadata.
191
+ In checked mode, Minitest `assert(value)` records `value` as the expectation
192
+ actual.
193
+
194
+ ## Notes
195
+
196
+ Line ranges use `RubyVM::AbstractSyntaxTree` on CRuby and fall back to
197
+ `method_source` when the VM does not expose AST ranges. Coverage from threads
198
+ created inside an example belongs to the same process and may appear in that
199
+ example's evidence.
200
+
201
+ If SimpleCov or another tool has already started Ruby coverage, `rspec-covers`
202
+ uses `Coverage.peek_result` deltas and does not restart coverage.
@@ -0,0 +1,26 @@
1
+ {
2
+ "targets": {
3
+ "traceability.f1": 0.75,
4
+ "strict.recall": 0.90,
5
+ "checked.f1": 0.70
6
+ },
7
+ "examples": [
8
+ {
9
+ "id": "spec/services/user_creator_spec.rb[1:1]",
10
+ "covers": ["UserCreator#call"],
11
+ "risky": false,
12
+ "checked_locations": [
13
+ { "file": "app/services/user_creator.rb", "line": 12 }
14
+ ]
15
+ },
16
+ {
17
+ "id": "spec/services/user_creator_spec.rb[1:2]",
18
+ "covers": ["UserCreator#call"],
19
+ "risky": true,
20
+ "seeded": true,
21
+ "checked_locations": [
22
+ { "file": "app/services/user_creator.rb", "line": 18 }
23
+ ]
24
+ }
25
+ ]
26
+ }
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest"
4
+ require "rspec/covers"
5
+
6
+ module Minitest
7
+ module Covers
8
+ class ExampleAdapter
9
+ attr_reader :metadata, :full_description
10
+
11
+ def initialize(test)
12
+ @test = test
13
+ file, line = source_location
14
+ @metadata = test.class.rspec_covers_metadata_for(test.name).merge(
15
+ id: id,
16
+ file_path: file,
17
+ line_number: line
18
+ )
19
+ @full_description = "#{test.class}##{test.name}"
20
+ end
21
+
22
+ def id
23
+ "#{@test.class}##{@test.name}"
24
+ end
25
+
26
+ def exception
27
+ @test.failures.first if @test.respond_to?(:failures)
28
+ end
29
+
30
+ private
31
+
32
+ def source_location
33
+ @test.method(@test.name).source_location || ["(unknown)", 0]
34
+ rescue NameError
35
+ ["(unknown)", 0]
36
+ end
37
+ end
38
+
39
+ module ClassMethods
40
+ def covers(*items, target: nil)
41
+ rspec_covers_metadata_store[metadata_key(target)][:covers].concat(items)
42
+ end
43
+
44
+ def uses(*items, target: nil)
45
+ rspec_covers_metadata_store[metadata_key(target)][:uses].concat(items)
46
+ end
47
+
48
+ def rspec_covers_metadata_for(test_name)
49
+ metadata = superclass.respond_to?(:rspec_covers_metadata_for) ? superclass.rspec_covers_metadata_for(nil) : base_metadata
50
+ own = rspec_covers_metadata_store.fetch(:__class__, base_metadata)
51
+ method_metadata = rspec_covers_metadata_store.fetch(metadata_key(test_name), base_metadata)
52
+
53
+ merge_metadata(metadata, own, method_metadata)
54
+ end
55
+
56
+ private
57
+
58
+ def rspec_covers_metadata_store
59
+ @rspec_covers_metadata_store ||= Hash.new { |hash, key| hash[key] = base_metadata }
60
+ end
61
+
62
+ def metadata_key(test_name)
63
+ test_name&.to_sym || :__class__
64
+ end
65
+
66
+ def base_metadata
67
+ { covers: [], uses: [] }
68
+ end
69
+
70
+ def merge_metadata(*items)
71
+ {
72
+ covers: items.flat_map { |item| item[:covers] },
73
+ uses: items.flat_map { |item| item[:uses] }
74
+ }
75
+ end
76
+ end
77
+
78
+ module InstanceMethods
79
+ def assert(test, msg = nil)
80
+ RSpec::Covers.record_expectation_actual(test)
81
+ super
82
+ end
83
+
84
+ def before_setup
85
+ Thread.current[:rspec_covers_expectation_object_ids] = []
86
+ @rspec_covers_before_snapshot = RSpec::Covers.coverage_probe.snapshot
87
+ RSpec::Covers.call_log_probe.start_example if call_log_enabled?
88
+ super
89
+ end
90
+
91
+ def after_teardown
92
+ super
93
+ ensure
94
+ begin
95
+ call_log = call_log_enabled? ? RSpec::Covers.call_log_probe.stop_example : empty_call_log
96
+ after_snapshot = RSpec::Covers.coverage_probe.snapshot
97
+ executed = RSpec::Covers.coverage_probe.difference(@rspec_covers_before_snapshot || {}, after_snapshot)
98
+
99
+ RSpec::Covers.process_example(ExampleAdapter.new(self), executed, call_log)
100
+ ensure
101
+ Thread.current[:rspec_covers_expectation_object_ids] = nil
102
+ end
103
+ end
104
+
105
+ private
106
+
107
+ def call_log_enabled?
108
+ config = RSpec::Covers.configuration
109
+ config.suggest || config.mode == :checked || config.validate_declarations
110
+ end
111
+
112
+ def empty_call_log
113
+ RSpec::Covers::Probe::CallLog.new(calls: [], returns_by_object_id: {})
114
+ end
115
+ end
116
+
117
+ module_function
118
+
119
+ def configure
120
+ yield RSpec::Covers.configuration if block_given?
121
+ install!
122
+ RSpec::Covers.configuration
123
+ end
124
+
125
+ def install!
126
+ return if @installed
127
+
128
+ ::Minitest::Test.extend(ClassMethods)
129
+ ::Minitest::Test.prepend(InstanceMethods)
130
+ @installed = true
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ require_relative "production_inventory"
6
+
7
+ module RSpec
8
+ module Covers
9
+ class CheckedCoverage
10
+ def initialize(config)
11
+ @inventory = ProductionInventory.new(config)
12
+ end
13
+
14
+ def filter(executed_locations:, call_log:, expectation_object_ids:)
15
+ object_ids = (expectation_object_ids + call_log.expectation_object_ids).uniq
16
+ return Set.new if object_ids.empty? && call_log.labels_before_expectations.empty?
17
+
18
+ checked_labels = (call_log.return_labels_for(object_ids) + call_log.labels_before_expectations).uniq
19
+ checked_locations = @inventory.methods_by_label(checked_labels).each_with_object(Set.new) do |entry, set|
20
+ set.merge(entry.region.locations)
21
+ end
22
+
23
+ Set.new(executed_locations).select { |location| checked_locations.include?(location) }.to_set
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module RSpec
6
+ module Covers
7
+ CodeLocation = Struct.new(:file, :line, keyword_init: true) do
8
+ def initialize(file:, line:)
9
+ super(file: File.expand_path(file), line: line.to_i)
10
+ end
11
+
12
+ def to_h
13
+ { file: file, line: line }
14
+ end
15
+ end
16
+
17
+ class CodeRegion
18
+ attr_reader :file, :lines, :label
19
+
20
+ def initialize(file:, lines:, label:)
21
+ @file = File.expand_path(file)
22
+ @lines = Set.new(lines.map(&:to_i))
23
+ @label = label
24
+ end
25
+
26
+ def include?(location)
27
+ file == File.expand_path(location.file) && lines.include?(location.line)
28
+ end
29
+
30
+ def locations
31
+ lines.map { |line| CodeLocation.new(file: file, line: line) }.to_set
32
+ end
33
+
34
+ def to_h
35
+ {
36
+ file: file,
37
+ lines: lines.to_a.sort,
38
+ label: label
39
+ }
40
+ end
41
+
42
+ def self.file(file, label: file)
43
+ line_count = File.exist?(file) ? File.readlines(file).length : 0
44
+
45
+ new(file: file, lines: 1..line_count, label: label)
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module RSpec
6
+ module Covers
7
+ class Configuration
8
+ VALID_MODES = %i[executed checked].freeze
9
+ VALID_UNDECLARED_POLICIES = %i[ignore warn risky].freeze
10
+ VALID_RISKY_POLICIES = %i[fail report].freeze
11
+
12
+ attr_accessor :strict, :allowlist, :suggest, :root, :production_paths,
13
+ :suggestion_weights, :report_path, :suggestion_limit,
14
+ :validate_declarations, :validation_threshold,
15
+ :json_events
16
+
17
+ def initialize
18
+ @strict = false
19
+ @mode = :executed
20
+ @undeclared = :ignore
21
+ @allowlist = []
22
+ @suggest = false
23
+ @root = Dir.pwd
24
+ @production_paths = %w[app lib]
25
+ @suggestion_weights = {
26
+ nc: 1.0,
27
+ ncc: 1.0,
28
+ lcba: 1.0,
29
+ dynamic: 1.0
30
+ }
31
+ @suggestion_limit = 3
32
+ @report_path = nil
33
+ @validate_declarations = true
34
+ @validation_threshold = 0.2
35
+ @risky = :fail
36
+ @json_events = false
37
+ end
38
+
39
+ attr_reader :mode, :undeclared, :risky
40
+
41
+ def mode=(value)
42
+ normalized = value.to_sym
43
+ unless VALID_MODES.include?(normalized)
44
+ raise ArgumentError, "mode must be one of #{VALID_MODES.join(", ")}"
45
+ end
46
+
47
+ @mode = normalized
48
+ end
49
+
50
+ def undeclared=(value)
51
+ normalized = value.to_sym
52
+ unless VALID_UNDECLARED_POLICIES.include?(normalized)
53
+ raise ArgumentError, "undeclared must be one of #{VALID_UNDECLARED_POLICIES.join(", ")}"
54
+ end
55
+
56
+ @undeclared = normalized
57
+ end
58
+
59
+ def risky=(value)
60
+ normalized = value.to_sym
61
+ unless VALID_RISKY_POLICIES.include?(normalized)
62
+ raise ArgumentError, "risky must be one of #{VALID_RISKY_POLICIES.join(", ")}"
63
+ end
64
+
65
+ @risky = normalized
66
+ end
67
+
68
+ def production_file?(path)
69
+ absolute = File.expand_path(path)
70
+ return false unless absolute.start_with?("#{expanded_root}#{File::SEPARATOR}")
71
+ return false unless File.extname(absolute) == ".rb"
72
+
73
+ relative = relative_path(absolute)
74
+ return false if relative.start_with?("spec/", "test/", "tmp/", ".")
75
+ return false if allowlisted?(absolute)
76
+
77
+ production_paths.any? { |pattern| production_path_match?(relative, pattern) }
78
+ end
79
+
80
+ def production_files
81
+ production_paths.flat_map { |path| files_for_production_path(path) }
82
+ .map { |path| File.expand_path(path) }
83
+ .select { |path| production_file?(path) }
84
+ .uniq
85
+ end
86
+
87
+ def allowlisted?(path)
88
+ absolute = File.expand_path(path)
89
+ relative = relative_path(absolute)
90
+
91
+ Array(allowlist).any? do |pattern|
92
+ expanded = File.expand_path(pattern.to_s, expanded_root)
93
+
94
+ File.fnmatch?(pattern.to_s, relative, File::FNM_PATHNAME | File::FNM_EXTGLOB) ||
95
+ File.fnmatch?(expanded, absolute, File::FNM_PATHNAME | File::FNM_EXTGLOB)
96
+ end
97
+ end
98
+
99
+ def relative_path(path)
100
+ Pathname.new(File.expand_path(path)).relative_path_from(Pathname.new(expanded_root)).to_s
101
+ rescue ArgumentError
102
+ File.expand_path(path)
103
+ end
104
+
105
+ private
106
+
107
+ def expanded_root
108
+ File.expand_path(root)
109
+ end
110
+
111
+ def production_path_match?(relative, pattern)
112
+ pattern = pattern.to_s.delete_suffix("/")
113
+
114
+ relative == pattern ||
115
+ relative.start_with?("#{pattern}/") ||
116
+ File.fnmatch?(pattern, relative, File::FNM_PATHNAME | File::FNM_EXTGLOB)
117
+ end
118
+
119
+ def files_for_production_path(path)
120
+ pattern = path.to_s
121
+ absolute = File.expand_path(pattern, expanded_root)
122
+
123
+ if pattern.match?(/[*?\[\]{}]/)
124
+ Dir.glob(absolute)
125
+ elsif File.directory?(absolute)
126
+ Dir.glob(File.join(absolute, "**", "*.rb"))
127
+ elsif File.file?(absolute)
128
+ [absolute]
129
+ else
130
+ Dir.glob(File.join(absolute, "**", "*.rb"))
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end