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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +202 -0
- data/data/evaluation/ground_truth.example.json +26 -0
- data/lib/minitest/covers.rb +133 -0
- data/lib/rspec/covers/checked_coverage.rb +27 -0
- data/lib/rspec/covers/code_location.rb +49 -0
- data/lib/rspec/covers/configuration.rb +135 -0
- data/lib/rspec/covers/declaration.rb +199 -0
- data/lib/rspec/covers/declaration_validation.rb +65 -0
- data/lib/rspec/covers/evaluation.rb +197 -0
- data/lib/rspec/covers/formatter.rb +70 -0
- data/lib/rspec/covers/integration.rb +54 -0
- data/lib/rspec/covers/metadata_reader.rb +34 -0
- data/lib/rspec/covers/method_entry.rb +19 -0
- data/lib/rspec/covers/method_label.rb +37 -0
- data/lib/rspec/covers/probe/call_log_probe.rb +132 -0
- data/lib/rspec/covers/probe/coverage_probe.rb +57 -0
- data/lib/rspec/covers/production_inventory.rb +90 -0
- data/lib/rspec/covers/rake_task.rb +63 -0
- data/lib/rspec/covers/reporter.rb +162 -0
- data/lib/rspec/covers/source_range.rb +37 -0
- data/lib/rspec/covers/static_method_scanner.rb +184 -0
- data/lib/rspec/covers/strict_verdict.rb +107 -0
- data/lib/rspec/covers/tracer/composite.rb +73 -0
- data/lib/rspec/covers/tracer/dynamic.rb +27 -0
- data/lib/rspec/covers/tracer/dynamic_corpus.rb +43 -0
- data/lib/rspec/covers/tracer/lcba.rb +17 -0
- data/lib/rspec/covers/tracer/nc.rb +36 -0
- data/lib/rspec/covers/tracer/ncc.rb +23 -0
- data/lib/rspec/covers/tracer/suggestion.rb +17 -0
- data/lib/rspec/covers/tracer/tokenizer.rb +18 -0
- data/lib/rspec/covers/version.rb +11 -0
- data/lib/rspec/covers.rb +225 -0
- data/sig/minitest/covers.rbs +6 -0
- data/sig/rspec/covers.rbs +27 -0
- 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
|