path_coverage 0.0.1 → 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 +4 -4
- data/LICENSE.md +22 -0
- data/README.md +353 -0
- data/exe/path_coverage_collector +37 -0
- data/lib/path_coverage/auto.rb +5 -0
- data/lib/path_coverage/calls.rb +67 -0
- data/lib/path_coverage/collector.rb +112 -0
- data/lib/path_coverage/condition.rb +96 -0
- data/lib/path_coverage/configuration.rb +130 -0
- data/lib/path_coverage/gaps.rb +60 -0
- data/lib/path_coverage/html/file.html +33 -0
- data/lib/path_coverage/html/index.html +41 -0
- data/lib/path_coverage/html/side.js +542 -0
- data/lib/path_coverage/html/sort.js +31 -0
- data/lib/path_coverage/html/style.css +338 -0
- data/lib/path_coverage/html/toggle.js +12 -0
- data/lib/path_coverage/html_file.rb +109 -0
- data/lib/path_coverage/html_index.rb +74 -0
- data/lib/path_coverage/html_nav.rb +67 -0
- data/lib/path_coverage/html_paths.rb +138 -0
- data/lib/path_coverage/html_report.rb +118 -0
- data/lib/path_coverage/html_template.rb +20 -0
- data/lib/path_coverage/html_tree.rb +146 -0
- data/lib/path_coverage/json_writer.rb +25 -0
- data/lib/path_coverage/kernel.rb +165 -0
- data/lib/path_coverage/manifest.rb +205 -0
- data/lib/path_coverage/manifest_blocks.rb +57 -0
- data/lib/path_coverage/manifest_case.rb +54 -0
- data/lib/path_coverage/manifest_choices.rb +132 -0
- data/lib/path_coverage/merge.rb +35 -0
- data/lib/path_coverage/path_gaps.rb +41 -0
- data/lib/path_coverage/path_manifest.rb +110 -0
- data/lib/path_coverage/paths_report.rb +55 -0
- data/lib/path_coverage/post.rb +26 -0
- data/lib/path_coverage/recorder.rb +182 -0
- data/lib/path_coverage/render.rb +92 -0
- data/lib/path_coverage/result.rb +59 -0
- data/lib/path_coverage/rewriter.rb +166 -0
- data/lib/path_coverage/site.rb +16 -0
- data/lib/path_coverage/site_paths.rb +86 -0
- data/lib/path_coverage/site_tree.rb +134 -0
- data/lib/path_coverage/source_lines.rb +79 -0
- data/lib/path_coverage/summary.rb +52 -0
- data/lib/path_coverage/totals.rb +29 -0
- data/lib/path_coverage/version.rb +3 -0
- data/lib/path_coverage.rb +2 -5
- metadata +73 -14
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7146cc322217826aadc2bd040b560b5e77d6fad7b6de4d2ae43e8bdfaa893940
|
|
4
|
+
data.tar.gz: 29135bab12a1cc92d1a15dd8d383d3e79d75b3b8716fff7efa95fbbb6f8c3b34
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4ebf32a5b048eb5d3d1b1cbbca0e2ed9c217e429481d1bd77a242f0a3baff83ee7b83a2b4a4cd99f6a6611885d1c165a56b6bd6f814be28c4cb6a92cffbc8282
|
|
7
|
+
data.tar.gz: 9ee6e1f2aea70b34ffb38ec5fa16a52f92ae5daf0a680727903db858fcdb557c244d0ae61a048e1a7bc09d99128c0b7068dd408686b2aca683b9f037afe3f6f3
|
data/LICENSE.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Copyright (c) 2026, [CyberDojo Foundation](http://blog.cyber-dojo.org/2015/08/cyber-dojo-foundation.html)
|
|
2
|
+
All rights reserved.
|
|
3
|
+
|
|
4
|
+
Redistribution and use in source and binary forms, with or without
|
|
5
|
+
modification, are permitted provided that the following conditions are met:
|
|
6
|
+
|
|
7
|
+
Redistributions of source code must retain the above copyright notice, this
|
|
8
|
+
list of conditions and the following disclaimer.
|
|
9
|
+
Redistributions in binary form must reproduce the above copyright notice,
|
|
10
|
+
this list of conditions and the following disclaimer in the documentation
|
|
11
|
+
and/or other materials provided with the distribution.
|
|
12
|
+
|
|
13
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
|
14
|
+
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
|
15
|
+
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
16
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
|
|
17
|
+
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
|
18
|
+
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
|
19
|
+
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
|
|
20
|
+
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
|
21
|
+
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
|
22
|
+
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
data/README.md
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
# path_coverage
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/path_coverage)
|
|
4
|
+
[](https://rubygems.org/gems/path_coverage)
|
|
5
|
+
[](LICENSE.md)
|
|
6
|
+
|
|
7
|
+
Path coverage for Ruby, to run alongside SimpleCov's line and branch
|
|
8
|
+
coverage.
|
|
9
|
+
|
|
10
|
+
Gem: https://rubygems.org/gems/path_coverage (which shows the latest
|
|
11
|
+
version).
|
|
12
|
+
|
|
13
|
+
Status: sites cover `&&`, `||`, `and`, `or`, the ternary `?:`,
|
|
14
|
+
`if`/`elsif`/`else` and `unless` (modifiers included), `case`/`when`
|
|
15
|
+
with a subject, and the block methods `any?` `all?` `none?` `find`
|
|
16
|
+
`detect` `find_index` `take_while` `select` `filter` `reject`
|
|
17
|
+
`partition` `count` `filter_map`. Method paths cover `if` decisions.
|
|
18
|
+
More decisions hidden inside calls come next (see Roadmap).
|
|
19
|
+
|
|
20
|
+
Demo: https://cyber-dojo.github.io/path_coverage/ shows the HTML report
|
|
21
|
+
for the mock source tree in `demo/`. `make demo` builds and opens the
|
|
22
|
+
same report locally.
|
|
23
|
+
|
|
24
|
+
Take this method:
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
def fee(user, order)
|
|
28
|
+
total = 100
|
|
29
|
+
if user.member? && order.paid?
|
|
30
|
+
total -= 5
|
|
31
|
+
end
|
|
32
|
+
if order.big?
|
|
33
|
+
total -= 10
|
|
34
|
+
end
|
|
35
|
+
total
|
|
36
|
+
end
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Tests that run both `if`s both ways reach every line and every branch,
|
|
40
|
+
so SimpleCov, which reads Ruby's built-in `Coverage`, reports 100%. Yet
|
|
41
|
+
they can still miss paths, at two scopes, and path_coverage lists each
|
|
42
|
+
path no test took.
|
|
43
|
+
|
|
44
|
+
## Within each decision
|
|
45
|
+
|
|
46
|
+
The first `if` can go three ways: member and paid, member and not
|
|
47
|
+
paid, not a member. A member who has not paid may never have been
|
|
48
|
+
tried.
|
|
49
|
+
|
|
50
|
+

|
|
52
|
+
|
|
53
|
+
## Across a method's decisions
|
|
54
|
+
|
|
55
|
+
The two `if`s can go four ways together. A paying member with a small
|
|
56
|
+
order may never have been tried.
|
|
57
|
+
|
|
58
|
+

|
|
60
|
+
|
|
61
|
+
## Use
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
# test coverage config, before any application code is required
|
|
65
|
+
require 'simplecov'
|
|
66
|
+
require 'path_coverage'
|
|
67
|
+
SimpleCov.start do
|
|
68
|
+
# ...
|
|
69
|
+
end
|
|
70
|
+
PathCoverage.start do
|
|
71
|
+
root APP_DIR # cover globs and site names are relative to it
|
|
72
|
+
cover 'source/**/*.rb' # each matching file is instrumented as it loads
|
|
73
|
+
coverage_dir '/tmp/coverage' # where path_coverage.json is written
|
|
74
|
+
measure :decisions, :methods # :decisions alone unless :methods is named
|
|
75
|
+
report :gaps, :html # the reporters run at exit (default :gaps)
|
|
76
|
+
end
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
A misspelt setting or an unknown reporter is refused by name, with the list
|
|
80
|
+
of the ones there are. Calling `start` again replaces the configuration.
|
|
81
|
+
|
|
82
|
+
A run whose code cannot be changed, such as a script in a `docker build`
|
|
83
|
+
step, can be covered from its environment alone:
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
RUBYOPT=-rpath_coverage/auto
|
|
87
|
+
PATH_COVERAGE_ROOT=/app # root
|
|
88
|
+
PATH_COVERAGE_COVER=src/**/*.rb,lib/*.rb # cover, comma-separated
|
|
89
|
+
PATH_COVERAGE_DIR=/tmp/paths # coverage_dir
|
|
90
|
+
PATH_COVERAGE_COLLECTOR=http://host.docker.internal:4570/runs
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
This starts with `report :post`. With no `PATH_COVERAGE_COLLECTOR` it
|
|
94
|
+
starts nothing and prints nothing, so an image can set `RUBYOPT` for every
|
|
95
|
+
run and measure only those given a collector.
|
|
96
|
+
|
|
97
|
+
The gem's `path_coverage_collector` command is that collector. It saves
|
|
98
|
+
each report POSTed to it as a numbered file, and once every run is done
|
|
99
|
+
they render as one report:
|
|
100
|
+
|
|
101
|
+
```sh
|
|
102
|
+
path_coverage_collector 4570 /tmp/paths/runs # until sent TERM
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
- A report is saved before it is answered 200, so a 200 means it is on
|
|
106
|
+
disk. A report cut short, eg by a sender dying mid-send, is answered
|
|
107
|
+
400 and not saved. One that cannot be saved is answered 500.
|
|
108
|
+
- `GET /alive` answers 200 while the collector runs.
|
|
109
|
+
- `GET /ready` answers 200 when its dir is writable, 503 when not. Wait
|
|
110
|
+
for it before starting the runs.
|
|
111
|
+
- On TERM it stops accepting connections, finishes the report in hand,
|
|
112
|
+
and exits 0.
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
runs = Dir['/tmp/paths/runs/*.json']
|
|
116
|
+
PathCoverage::Render.from_json(config, runs)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## What a run reports
|
|
120
|
+
|
|
121
|
+
As the process exits:
|
|
122
|
+
|
|
123
|
+
- `path_coverage.json` is always written to `coverage_dir`: every site
|
|
124
|
+
of every covered file, with each operand's true and false counts under
|
|
125
|
+
`sites`, and each site's paths under `paths`. It is the base the other
|
|
126
|
+
reporters read, so they can also be run later, in another process, from
|
|
127
|
+
it alone: `PathCoverage::Render.from_json(config)`, where `config` is a
|
|
128
|
+
`PathCoverage::Configuration` built from the same settings block.
|
|
129
|
+
Given several runs' reports, `Render.from_json(config, paths)` adds
|
|
130
|
+
their counts together first, so the report shows only the gaps that
|
|
131
|
+
every run left.
|
|
132
|
+
- `:gaps` prints one line per path never taken, in manifest order: a
|
|
133
|
+
site's naming the outcome of each leaf on it, a method's the outcome
|
|
134
|
+
of each decision on it, by line:
|
|
135
|
+
|
|
136
|
+
```text
|
|
137
|
+
source/kata.rb:3:23 path never taken: `ok?(id)` true, `new?(id)` false
|
|
138
|
+
source/kata.rb:4:24 never reached
|
|
139
|
+
source/fee.rb:2:2 fee path never taken: line 4 true, line 7 false
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Decisions
|
|
143
|
+
|
|
144
|
+
Measured always, as `:decisions`: the paths within each decision.
|
|
145
|
+
|
|
146
|
+
A site is named by the `path:line:column` where it starts, the path
|
|
147
|
+
relative to `root`. A site in a covered file the run never loaded is
|
|
148
|
+
still reported, as `never reached`.
|
|
149
|
+
|
|
150
|
+
A path is the outcome of each leaf one evaluation of the site reached, in
|
|
151
|
+
order; a leaf that short-circuiting skipped is not on it. Each leaf is
|
|
152
|
+
quoted as a Markdown code span, on one line, its fence one backtick longer
|
|
153
|
+
than any run of backticks inside it. The gap lines are for people; a tool
|
|
154
|
+
should read the JSON, whose `paths` give each site's leaves as
|
|
155
|
+
`line:column` spans, and each possible path with its count:
|
|
156
|
+
|
|
157
|
+
```json
|
|
158
|
+
"paths": {
|
|
159
|
+
"source/kata.rb:3:23": {
|
|
160
|
+
"leaves": [["3:23", "3:30"], ["3:34", "3:42"]],
|
|
161
|
+
"taken": [
|
|
162
|
+
{ "path": [true, true], "count": 2 },
|
|
163
|
+
{ "path": [true, false], "count": 0 },
|
|
164
|
+
{ "path": [false, null], "count": 1 }
|
|
165
|
+
]
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
A site is an outermost condition. Its leaves are numbered left to right
|
|
171
|
+
across the whole of it, nested conditions included:
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
ok?(id) && new?(id) && id.size == 6 # one && site, leaves 0 1 2
|
|
175
|
+
ok?(id) && (new?(id) || id.size == 6) # an || nested in an && site, 0 1 2
|
|
176
|
+
ok?(id) && new?(id) || id.size == 6 # an && nested in an || site, 0 1 2
|
|
177
|
+
ids.map { |k| k && k.ok? } && ok?(id) # two sites: the block's is its own
|
|
178
|
+
ok?(id) ? :ok : :no # a ?: site, its predicate leaf 0
|
|
179
|
+
ok?(id) && new?(id) ? :ok : :no # an && nested in a ?: site, 0 1
|
|
180
|
+
ok?(id) ? new?(id) && id.size == 6 : :no # one ?: site, its then arm 1 2
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
A ternary is a site even when its predicate is a single leaf. An arm
|
|
184
|
+
holding a condition of `&&`, `||`, `and`, `or` or `?:` is part of the
|
|
185
|
+
ternary's site, its leaves numbered after the predicate's, so the site's
|
|
186
|
+
paths run through whichever arm the predicate picks. Any other arm, like a
|
|
187
|
+
block, is not part of it, though a condition inside it is a site of its
|
|
188
|
+
own.
|
|
189
|
+
|
|
190
|
+
An `if` or an `unless`, a modifier included, is a site too, even when
|
|
191
|
+
its predicate is a single leaf: its predicate nests in it, an `&&` there
|
|
192
|
+
being no site of its own, and its `then` and `else` arms are plain, as
|
|
193
|
+
they are statements; an `unless` takes its `then` arm when its predicate
|
|
194
|
+
is false. It is named at its keyword, and its extent is its keyword to
|
|
195
|
+
the end of its predicate, so a site inside an arm keeps its own button.
|
|
196
|
+
An `elsif` is a site of its own.
|
|
197
|
+
|
|
198
|
+
A `case` with a subject is a site whose leaves are its `when` patterns,
|
|
199
|
+
tried in order until one matches. Each pattern is wrapped in a stand-in
|
|
200
|
+
whose `===` calls the pattern's `===` once, exactly as the `case` would,
|
|
201
|
+
and notes whether it matched. A `case` with no subject, or with a splat
|
|
202
|
+
among its patterns, is no site.
|
|
203
|
+
|
|
204
|
+
A call to a block method, with no arguments and a block of one
|
|
205
|
+
expression, is a site named at its method. Its two leaves are its
|
|
206
|
+
receiver, showing how many items its block was called with (`0 items`,
|
|
207
|
+
`1 item` or `2+ items`), and its block's expression: for `any?` `all?`
|
|
208
|
+
`none?` `find` `detect` `find_index` and `take_while`, which stop once
|
|
209
|
+
their block decides, its last answer; for `select` `filter` `reject`
|
|
210
|
+
`partition` `count` and `filter_map`, which call it with every item,
|
|
211
|
+
whether its answers were `all true`, `all false` or `both`. Both are
|
|
212
|
+
noted from the block's own calls, so the receiver is never asked for its
|
|
213
|
+
size, which could consume a sequence that can be read only once.
|
|
214
|
+
|
|
215
|
+
A chain of one operator is flat; a change of operator, by precedence or by
|
|
216
|
+
parentheses, nests. Parentheses are not part of the tree, since they decide
|
|
217
|
+
nothing.
|
|
218
|
+
|
|
219
|
+
A leaf whose form allows one outcome only needs only to be evaluated: a
|
|
220
|
+
path needing its other outcome is impossible, not a gap. `Foo.new`,
|
|
221
|
+
literals (`'s'`, `1`, `:k`, `[]`, `{}`, `true`) and lambdas can only be
|
|
222
|
+
truthy, as can `$stdout` and `$stderr`, which Ruby refuses to set to nil
|
|
223
|
+
or false. Other globals, `$stdin` among them, can be either, as can
|
|
224
|
+
constants, since one can hold nil or false (`VERBOSE = false`). `nil` and
|
|
225
|
+
`false` can only be falsy:
|
|
226
|
+
|
|
227
|
+
```ruby
|
|
228
|
+
@clock = options[:clock] || MonotonicClock.new # leaf 1: never evaluated, or no gap
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
### Methods
|
|
232
|
+
|
|
233
|
+
With `measure :decisions, :methods`, each method with two or more
|
|
234
|
+
decisions also counts each call's path through them: the outcome of each
|
|
235
|
+
decision it reached, in order. A method is named by the `path:line:column`
|
|
236
|
+
of its `def`, then its name. Each decision is one outcome on the path,
|
|
237
|
+
however many leaves its predicate has; those are its site's to show. So a
|
|
238
|
+
site's columns stay its own leaves' combinations, and a method's its own
|
|
239
|
+
decisions', rather than multiplying together. A decision whose arm always
|
|
240
|
+
leaves the method ends the path there.
|
|
241
|
+
|
|
242
|
+
### Reporters
|
|
243
|
+
|
|
244
|
+
- `:html` writes `index.html` to `coverage_dir`: the covered files with
|
|
245
|
+
their sites and their paths taken of all possible, within each decision
|
|
246
|
+
and, when `:methods` is measured, across a method's decisions (eg
|
|
247
|
+
`9 / 28 (32%)`, or `-` for a file with no method of two or more
|
|
248
|
+
decisions), each column sortable, and a Total row for every file
|
|
249
|
+
together. Each file name opens `files/<path>.html`, the file's
|
|
250
|
+
source. On a line holding a site the line number is a button (blue
|
|
251
|
+
and outlined when every outcome was seen, solid orange when any is
|
|
252
|
+
missing) that opens a grid of the site's paths, on the left. Its rows
|
|
253
|
+
are the site's tree: its operators, nested as Ruby groups them, a
|
|
254
|
+
ternary's arms marked `?` and `:`, an `if`'s named `then` and `else`,
|
|
255
|
+
and its leaves. Its columns are the site's paths, never taken first,
|
|
256
|
+
each headed by its count, orange when zero; a leaf shows its outcome on
|
|
257
|
+
each path, T or F, blank where the path never reached it, and a plain
|
|
258
|
+
arm a dot on each path that took it. A site written over several lines
|
|
259
|
+
has one button over all of its line numbers. Grids with gaps start
|
|
260
|
+
open. A header that stays at the top of the page lists every site as a
|
|
261
|
+
link to its line, covered sites first, then those with gaps, and arrows
|
|
262
|
+
beside each site with gaps jump to the previous and next one, wrapping
|
|
263
|
+
round at either end. Measuring paths, each method's grid of its
|
|
264
|
+
decisions' paths opens on the right.
|
|
265
|
+
- `:post` sends the JSON report, as an HTTP POST, to the url set by
|
|
266
|
+
`collector 'http://...'`. It is for a run whose own files are thrown
|
|
267
|
+
away, such as a failing `docker build` step. It prints nothing, never
|
|
268
|
+
goes through an `http_proxy`, and gives up in silence on a collector
|
|
269
|
+
that cannot be reached or does not answer within a second, so the
|
|
270
|
+
run's output and exit status are unchanged.
|
|
271
|
+
|
|
272
|
+
## How it works
|
|
273
|
+
|
|
274
|
+
At load time each covered file is read and parsed once, with Prism. The
|
|
275
|
+
parse gives the manifest: each site's condition tree and the byte range of
|
|
276
|
+
each of its leaves. The rewriter then inserts a record call around each
|
|
277
|
+
leaf, and a leave call around the whole site, whose first argument enters
|
|
278
|
+
it, without parsing again, and the result is compiled in place of the
|
|
279
|
+
file:
|
|
280
|
+
|
|
281
|
+
```ruby
|
|
282
|
+
ok?(id) && new?(id)
|
|
283
|
+
# compiles as
|
|
284
|
+
PathCoverage.leave(PathCoverage.enter("k.rb:2:2", 2), PathCoverage.record("k.rb:2:2", 0, ok?(id)) && PathCoverage.record("k.rb:2:2", 1, new?(id)))
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
An `if` site's leave call wraps its predicate alone, as its path is
|
|
288
|
+
decided there, so an arm that returns or raises still counts it.
|
|
289
|
+
Measuring paths, each method starts a path at its entry, and each
|
|
290
|
+
decision's predicate is wrapped in a decide call too, outside its site's
|
|
291
|
+
calls.
|
|
292
|
+
|
|
293
|
+
A command call such as `ok? id` (after `and`) is not valid as a bare
|
|
294
|
+
argument, so that leaf alone gets parentheses of its own, and so does a
|
|
295
|
+
whole `and` or `or` inside its leave call. Nothing else does:
|
|
296
|
+
parentheses make a statement, and a statement on a continuation line gives
|
|
297
|
+
that line a line-coverage count of its own, which changes SimpleCov's line
|
|
298
|
+
totals. So a command call, or an `and` or `or`, written over several lines
|
|
299
|
+
can add to SimpleCov's relevant lines; nothing else does.
|
|
300
|
+
|
|
301
|
+
`record` counts whether its value was truthy or falsy, and notes it on the
|
|
302
|
+
site's current path; `enter` starts that path, and `leave` counts it.
|
|
303
|
+
Each returns its value unchanged, so short-circuiting and the value of
|
|
304
|
+
the expression are preserved. Paths are kept per fiber, so threads never
|
|
305
|
+
mix their outcomes; an evaluation cut short by a raise counts no path,
|
|
306
|
+
so no path is ever claimed that was not taken. Only text is inserted,
|
|
307
|
+
never a newline, so backtrace line numbers still match the file. The
|
|
308
|
+
rewrite happens in memory; no file on disk changes.
|
|
309
|
+
|
|
310
|
+
At exit the manifests parsed during the run are reused; only covered files
|
|
311
|
+
the run never loaded are read and parsed then.
|
|
312
|
+
|
|
313
|
+
## Roadmap
|
|
314
|
+
|
|
315
|
+
- decisions hidden inside calls, each with its own outcome classes:
|
|
316
|
+
- `one?`, whose last answer does not decide it, and block methods
|
|
317
|
+
with blocks of more than one expression
|
|
318
|
+
- `fetch(k, default)`, `dig`, `Hash#[]` with a default: present vs default
|
|
319
|
+
- `x || default` in value context, `&.` safe navigation: nil vs not nil
|
|
320
|
+
- `||=` and `&&=`, on variables, attributes and index targets: the
|
|
321
|
+
right side evaluated vs skipped, counted without evaluating the
|
|
322
|
+
target twice
|
|
323
|
+
- `x rescue y` modifier: raised vs not raised
|
|
324
|
+
- `case` with no subject, and `case`/`in` patterns
|
|
325
|
+
- regex matches: nil vs MatchData
|
|
326
|
+
|
|
327
|
+
## Constraints
|
|
328
|
+
|
|
329
|
+
- CRuby only: the hook is `RubyVM::InstructionSequence.load_iseq`.
|
|
330
|
+
- Ruby 3.2 or later, for `String#bytesplice`.
|
|
331
|
+
- That hook has a single slot, which Bootsnap also uses, so the two cannot
|
|
332
|
+
both be active in one process.
|
|
333
|
+
|
|
334
|
+
## Development
|
|
335
|
+
|
|
336
|
+
`make all` runs, in a Docker image built from the repo's `Dockerfile`:
|
|
337
|
+
|
|
338
|
+
- `make lint`: rubocop over `lib/`, `test/` and `bin/` alike
|
|
339
|
+
- `make test`: the tests, then a check that the coverage report has zero
|
|
340
|
+
missed lines and branches in both the code and the tests, within the
|
|
341
|
+
bounds in `test/coverage_metrics_params.json`
|
|
342
|
+
(`make test tids='6D1K'` runs only matching tests, and skips the check)
|
|
343
|
+
- `make test_check`: shunit2 tests showing that a breached bound fails the
|
|
344
|
+
check with a non-zero exit
|
|
345
|
+
|
|
346
|
+
`make demo` (not part of `make all`) runs `demo/run.rb` through
|
|
347
|
+
`bin/build_demo.sh`, writing the report to `site/` and opening it. The
|
|
348
|
+
Pages workflow (`.github/workflows/pages.yml`) runs the same script on
|
|
349
|
+
each push to `main` and publishes `site/`.
|
|
350
|
+
|
|
351
|
+
`bin/capture_readme_images.sh` rebuilds the demo and saves this README's
|
|
352
|
+
two screenshots, `docs/decision.png` and `docs/method.png`, from it with
|
|
353
|
+
headless Chrome. Run it again whenever the report's look changes.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
require 'fileutils'
|
|
3
|
+
require 'socket'
|
|
4
|
+
require 'path_coverage/collector'
|
|
5
|
+
|
|
6
|
+
if ARGV.first == '-h'
|
|
7
|
+
puts <<~HELP
|
|
8
|
+
Usage: path_coverage_collector PORT DIR
|
|
9
|
+
|
|
10
|
+
Listens on PORT and saves each JSON report the :post reporter
|
|
11
|
+
sends as DIR/run-0001.json, DIR/run-0002.json, and so on,
|
|
12
|
+
creating DIR if need be. A 200 means the report is saved.
|
|
13
|
+
|
|
14
|
+
GET /alive answers 200 while it runs.
|
|
15
|
+
GET /ready answers 200 when DIR is writable, 503 when not.
|
|
16
|
+
|
|
17
|
+
Runs until sent TERM. It then stops accepting connections,
|
|
18
|
+
finishes the report in hand, and exits 0.
|
|
19
|
+
|
|
20
|
+
Options:
|
|
21
|
+
-h Show this help
|
|
22
|
+
|
|
23
|
+
Example:
|
|
24
|
+
path_coverage_collector 4570 /tmp/condition/runs
|
|
25
|
+
HELP
|
|
26
|
+
exit 0
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
port, dir = ARGV
|
|
30
|
+
FileUtils.mkdir_p(dir)
|
|
31
|
+
server = TCPServer.new('0.0.0.0', Integer(port))
|
|
32
|
+
# Closing the server on TERM makes serve return once the
|
|
33
|
+
# report in hand, if any, is saved and answered. The close
|
|
34
|
+
# runs in a thread of its own, as the trap runs on the thread
|
|
35
|
+
# that may be waiting in accept, where a close deadlocks.
|
|
36
|
+
Signal.trap('TERM') { Thread.new { server.close } }
|
|
37
|
+
PathCoverage::Collector.serve(server, dir)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# The calls rewritten code makes, each handed to the kernel's
|
|
2
|
+
# one recorder. Like the kernel, this file loads before any
|
|
3
|
+
# covered file and is never itself instrumented.
|
|
4
|
+
module PathCoverage
|
|
5
|
+
# The call rewritten code makes around each operand; returns
|
|
6
|
+
# value unchanged.
|
|
7
|
+
def self.record(site, index, value)
|
|
8
|
+
@recorder.record(site, index, value)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# The call rewritten code makes before a site's first leaf,
|
|
12
|
+
# site having size leaves; returns the evaluation's trail.
|
|
13
|
+
def self.enter(site, size)
|
|
14
|
+
@recorder.enter(site, size)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# The call rewritten code makes around a site as a whole,
|
|
18
|
+
# with the trail enter gave it; returns value unchanged.
|
|
19
|
+
def self.leave(trail, value)
|
|
20
|
+
@recorder.leave(trail, value)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The call rewritten code makes around a case's pattern, the
|
|
24
|
+
# index-th of size at site; returns the pattern's stand-in.
|
|
25
|
+
def self.when_match(site, index, size, pattern)
|
|
26
|
+
@recorder.when_match(site, index, size, pattern)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The call rewritten code makes before a block method's call
|
|
30
|
+
# at site runs; returns the evaluation's trail.
|
|
31
|
+
def self.enter_items(site)
|
|
32
|
+
@recorder.enter_items(site)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The call rewritten code makes around a block method's
|
|
36
|
+
# block's expression at site; returns value unchanged.
|
|
37
|
+
def self.item(site, value)
|
|
38
|
+
@recorder.item(site, value)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The call rewritten code makes around the block's expression
|
|
42
|
+
# of a block method that calls it with every item; returns
|
|
43
|
+
# value unchanged.
|
|
44
|
+
def self.answer(site, value)
|
|
45
|
+
@recorder.answer(site, value)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The call rewritten code makes around a block method's call
|
|
49
|
+
# as a whole, with the trail enter_items gave it; returns
|
|
50
|
+
# value unchanged.
|
|
51
|
+
def self.leave_items(trail, value)
|
|
52
|
+
@recorder.leave_items(trail, value)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The call rewritten code makes as a call reaches its
|
|
56
|
+
# method's first decision; returns the call's new path.
|
|
57
|
+
def self.path(site, size)
|
|
58
|
+
@recorder.path(site, size)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The call rewritten code makes around each decision's
|
|
62
|
+
# predicate, with exits for one whose arm leaves the method;
|
|
63
|
+
# returns value unchanged.
|
|
64
|
+
def self.decide(path, index, value, exits = nil)
|
|
65
|
+
@recorder.decide(path, index, value, exits)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
module PathCoverage
|
|
2
|
+
# Receives the JSON reports the :post reporter sends, so the
|
|
3
|
+
# runs that sent them, eg failing docker build steps, can
|
|
4
|
+
# be merged and rendered once they are all done.
|
|
5
|
+
module Collector
|
|
6
|
+
# Answers every request on server until server is closed,
|
|
7
|
+
# then returns.
|
|
8
|
+
# Each report is saved in dir as run-0001.json,
|
|
9
|
+
# run-0002.json, and so on, in the order they arrive.
|
|
10
|
+
# A connection sending no request is skipped.
|
|
11
|
+
def self.serve(server, dir)
|
|
12
|
+
n = 0
|
|
13
|
+
while (client = accept(server))
|
|
14
|
+
saved = receive(client, dir) do |body|
|
|
15
|
+
save(dir, format('run-%<n>04d.json', n: n + 1), body)
|
|
16
|
+
end
|
|
17
|
+
n += 1 if saved
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The next connection to server; nil once server is closed,
|
|
22
|
+
# which is how serve is told to return.
|
|
23
|
+
def self.accept(server)
|
|
24
|
+
server.accept
|
|
25
|
+
rescue IOError
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Saves body as dir/name whole or not at all: it is written
|
|
30
|
+
# to name.tmp and renamed, so a collector stopped mid-write
|
|
31
|
+
# leaves no partial report under a run's name.
|
|
32
|
+
def self.save(dir, name, body)
|
|
33
|
+
path = "#{dir}/#{name}"
|
|
34
|
+
File.write("#{path}.tmp", body)
|
|
35
|
+
File.rename("#{path}.tmp", path)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Hands the body of the one HTTP request on client to the
|
|
39
|
+
# block, which saves it, and returns whether it was saved.
|
|
40
|
+
# dir is where reports are saved, which GET /ready checks.
|
|
41
|
+
# A client that closes without sending a request, eg a
|
|
42
|
+
# check that the collector is up, is closed unanswered and
|
|
43
|
+
# nothing is saved.
|
|
44
|
+
def self.receive(client, dir, &)
|
|
45
|
+
headers = []
|
|
46
|
+
while (line = client.gets) && line != "\r\n"
|
|
47
|
+
headers << line
|
|
48
|
+
end
|
|
49
|
+
saved = !headers.empty? && respond(client, headers, dir, &)
|
|
50
|
+
client.close
|
|
51
|
+
saved
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The check each GET path makes of the collector's dir.
|
|
55
|
+
# GET /alive passes whenever the collector answers.
|
|
56
|
+
# GET /ready passes only when dir is a writable directory,
|
|
57
|
+
# so a report sent now could be saved.
|
|
58
|
+
PROBES = {
|
|
59
|
+
'/alive' => ->(_dir) { true },
|
|
60
|
+
'/ready' => lambda do |dir|
|
|
61
|
+
File.directory?(dir) && File.writable?(dir)
|
|
62
|
+
end
|
|
63
|
+
}.freeze
|
|
64
|
+
|
|
65
|
+
# Answers the request with headers on client, and returns
|
|
66
|
+
# whether a report was saved in dir.
|
|
67
|
+
# A GET of a path in PROBES is answered 200 when its check
|
|
68
|
+
# passes, 503 when not. Nothing is saved for it.
|
|
69
|
+
# Any other request is a report, handed to answer.
|
|
70
|
+
def self.respond(client, headers, dir, &)
|
|
71
|
+
probe = PROBES[headers.first[/\AGET (\S+) /, 1]]
|
|
72
|
+
return answer(client, headers, &) if probe.nil?
|
|
73
|
+
|
|
74
|
+
up = probe.call(dir)
|
|
75
|
+
reply(client, up ? '200 OK' : '503 Service Unavailable')
|
|
76
|
+
false
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Reads the body of a request with headers from client and
|
|
80
|
+
# yields it to be saved. Returns whether it was saved.
|
|
81
|
+
# The 200 is sent only once it is saved.
|
|
82
|
+
# So a 200 always means the report is on disk.
|
|
83
|
+
# A body shorter than its Content-Length is answered 400.
|
|
84
|
+
# Eg its sender died mid-send. It is not saved.
|
|
85
|
+
# A save that fails is answered 500.
|
|
86
|
+
def self.answer(client, headers)
|
|
87
|
+
length = content_length(headers)
|
|
88
|
+
body = client.read(length).to_s
|
|
89
|
+
whole = body.bytesize == length
|
|
90
|
+
yield body if whole
|
|
91
|
+
reply(client, whole ? '200 OK' : '400 Bad Request')
|
|
92
|
+
whole
|
|
93
|
+
rescue SystemCallError
|
|
94
|
+
reply(client, '500 Internal Server Error')
|
|
95
|
+
false
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The byte length headers give the request's body; 0 when
|
|
99
|
+
# they give none.
|
|
100
|
+
def self.content_length(headers)
|
|
101
|
+
headers.sum do |line|
|
|
102
|
+
name, value = line.split(':', 2)
|
|
103
|
+
name.casecmp?('content-length') ? value.to_i : 0
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Writes an HTTP response of status, with no body, to client.
|
|
108
|
+
def self.reply(client, status)
|
|
109
|
+
client.write("HTTP/1.1 #{status}\r\nContent-Length: 0\r\n\r\n")
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|