path_coverage 0.0.1 → 0.0.2
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 +316 -0
- data/exe/path_coverage_collector +26 -0
- data/lib/path_coverage/auto.rb +5 -0
- data/lib/path_coverage/collector.rb +46 -0
- data/lib/path_coverage/condition.rb +45 -0
- data/lib/path_coverage/configuration.rb +127 -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 +38 -0
- data/lib/path_coverage/html/side.js +531 -0
- data/lib/path_coverage/html/sort.js +28 -0
- data/lib/path_coverage/html/style.css +334 -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 +43 -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 +103 -0
- data/lib/path_coverage/html_template.rb +20 -0
- data/lib/path_coverage/html_tree.rb +137 -0
- data/lib/path_coverage/json_writer.rb +21 -0
- data/lib/path_coverage/kernel.rb +179 -0
- data/lib/path_coverage/manifest.rb +177 -0
- data/lib/path_coverage/manifest_choices.rb +113 -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 +99 -0
- data/lib/path_coverage/render.rb +92 -0
- data/lib/path_coverage/result.rb +59 -0
- data/lib/path_coverage/rewriter.rb +120 -0
- data/lib/path_coverage/site.rb +11 -0
- data/lib/path_coverage/site_paths.rb +74 -0
- data/lib/path_coverage/site_tree.rb +94 -0
- data/lib/path_coverage/source_lines.rb +79 -0
- data/lib/path_coverage/summary.rb +33 -0
- data/lib/path_coverage/version.rb +3 -0
- data/lib/path_coverage.rb +2 -5
- metadata +68 -13
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: acac92dac750f3680a27856c6e410469f26c48c6ec23d6363d8b22caba5bc30c
|
|
4
|
+
data.tar.gz: c19de09501f409d8209ddb1071f37e44b926d9a4381ec302a6c460961ff162f8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2ec2945b4797a6b1f51f558c2cf2348307e1b6da68e5812c8ad4dfcab51f85926716f425c695cfd7456fec679d17fabf2e6b1400f7f6e131b0ccd9608aed5bce
|
|
7
|
+
data.tar.gz: 196816d6db1d908d2c578f7c547a9464025187d03c344537b741106faee39aa6c0c1127759456989442497fbc7a33f773a0ca096f92e5aaa599cbec9949f27f6
|
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,316 @@
|
|
|
1
|
+
# path_coverage
|
|
2
|
+
|
|
3
|
+
Path coverage for Ruby, to run alongside SimpleCov's line and branch
|
|
4
|
+
coverage.
|
|
5
|
+
|
|
6
|
+
Gem: https://rubygems.org/gems/path_coverage (which shows the latest
|
|
7
|
+
version).
|
|
8
|
+
|
|
9
|
+
Status: sites cover `&&`, `||`, `and`, `or`, the ternary `?:`, and
|
|
10
|
+
`if`/`elsif`/`else`: each leaf operand of a condition, however its
|
|
11
|
+
operators are chained, nested or grouped. Method paths cover `if`
|
|
12
|
+
decisions. Decisions hidden inside calls come next (see Roadmap).
|
|
13
|
+
|
|
14
|
+
Demo: https://cyber-dojo.github.io/path_coverage/ shows the HTML report
|
|
15
|
+
for the mock source tree in `demo/`. `make demo` builds and opens the
|
|
16
|
+
same report locally.
|
|
17
|
+
|
|
18
|
+
Take this method:
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
def fee(user, order)
|
|
22
|
+
total = 100
|
|
23
|
+
if user.member? && order.paid?
|
|
24
|
+
total -= 5
|
|
25
|
+
end
|
|
26
|
+
if order.big?
|
|
27
|
+
total -= 10
|
|
28
|
+
end
|
|
29
|
+
total
|
|
30
|
+
end
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Tests that run both `if`s both ways reach every line and every branch,
|
|
34
|
+
so SimpleCov, which reads Ruby's built-in `Coverage`, reports 100%. Yet
|
|
35
|
+
they can still miss paths, at two scopes, and path_coverage lists each
|
|
36
|
+
path no test took.
|
|
37
|
+
|
|
38
|
+
## Within each decision
|
|
39
|
+
|
|
40
|
+
The first `if` can go three ways: member and paid, member and not
|
|
41
|
+
paid, not a member. A member who has not paid may never have been
|
|
42
|
+
tried.
|
|
43
|
+
|
|
44
|
+

|
|
46
|
+
|
|
47
|
+
## Across a method's decisions
|
|
48
|
+
|
|
49
|
+
The two `if`s can go four ways together. A paying member with a small
|
|
50
|
+
order may never have been tried.
|
|
51
|
+
|
|
52
|
+

|
|
54
|
+
|
|
55
|
+
## Use
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
# test coverage config, before any application code is required
|
|
59
|
+
require 'simplecov'
|
|
60
|
+
require 'path_coverage'
|
|
61
|
+
SimpleCov.start do
|
|
62
|
+
# ...
|
|
63
|
+
end
|
|
64
|
+
PathCoverage.start do
|
|
65
|
+
root APP_DIR # cover globs and site names are relative to it
|
|
66
|
+
cover 'source/**/*.rb' # each matching file is instrumented as it loads
|
|
67
|
+
coverage_dir '/tmp/coverage' # where path_coverage.json is written
|
|
68
|
+
measure :conditions, :paths # sites alone unless :paths is named too
|
|
69
|
+
report :gaps, :html # the reporters run at exit (default :gaps)
|
|
70
|
+
end
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
A misspelt setting or an unknown reporter is refused by name, with the list
|
|
74
|
+
of the ones there are. Calling `start` again replaces the configuration.
|
|
75
|
+
|
|
76
|
+
A run whose code cannot be changed, such as a script in a `docker build`
|
|
77
|
+
step, can be covered from its environment alone:
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
RUBYOPT=-rpath_coverage/auto
|
|
81
|
+
PATH_COVERAGE_ROOT=/app # root
|
|
82
|
+
PATH_COVERAGE_COVER=src/**/*.rb,lib/*.rb # cover, comma-separated
|
|
83
|
+
PATH_COVERAGE_DIR=/tmp/paths # coverage_dir
|
|
84
|
+
PATH_COVERAGE_COLLECTOR=http://host.docker.internal:4570/runs
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
This starts with `report :post`. With no `PATH_COVERAGE_COLLECTOR` it
|
|
88
|
+
starts nothing and prints nothing, so an image can set `RUBYOPT` for every
|
|
89
|
+
run and measure only those given a collector.
|
|
90
|
+
|
|
91
|
+
The gem's `path_coverage_collector` command is that collector. It saves
|
|
92
|
+
each report POSTed to it as a numbered file, and once every run is done
|
|
93
|
+
they render as one report:
|
|
94
|
+
|
|
95
|
+
```sh
|
|
96
|
+
path_coverage_collector 4570 /tmp/paths/runs # until stopped
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
runs = Dir['/tmp/paths/runs/*.json']
|
|
101
|
+
PathCoverage::Render.from_json(config, runs)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## What a run reports
|
|
105
|
+
|
|
106
|
+
As the process exits:
|
|
107
|
+
|
|
108
|
+
- `path_coverage.json` is always written to `coverage_dir`: every site
|
|
109
|
+
of every covered file, with each operand's true and false counts under
|
|
110
|
+
`sites`, and each site's paths under `paths`. It is the base the other
|
|
111
|
+
reporters read, so they can also be run later, in another process, from
|
|
112
|
+
it alone: `PathCoverage::Render.from_json(config)`, where `config` is a
|
|
113
|
+
`PathCoverage::Configuration` built from the same settings block.
|
|
114
|
+
Given several runs' reports, `Render.from_json(config, paths)` adds
|
|
115
|
+
their counts together first, so the report shows only the gaps that
|
|
116
|
+
every run left.
|
|
117
|
+
- `:gaps` prints one line per path never taken, in manifest order: a
|
|
118
|
+
site's naming the outcome of each leaf on it, a method's the outcome
|
|
119
|
+
of each decision on it, by line:
|
|
120
|
+
|
|
121
|
+
```text
|
|
122
|
+
source/kata.rb:3:23 path never taken: `ok?(id)` true, `new?(id)` false
|
|
123
|
+
source/kata.rb:4:24 never reached
|
|
124
|
+
source/fee.rb:2:2 fee path never taken: line 4 true, line 7 false
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Sites
|
|
128
|
+
|
|
129
|
+
A site is named by the `path:line:column` where it starts, the path
|
|
130
|
+
relative to `root`. A site in a covered file the run never loaded is
|
|
131
|
+
still reported, as `never reached`.
|
|
132
|
+
|
|
133
|
+
A path is the outcome of each leaf one evaluation of the site reached, in
|
|
134
|
+
order; a leaf that short-circuiting skipped is not on it. Each leaf is
|
|
135
|
+
quoted as a Markdown code span, on one line, its fence one backtick longer
|
|
136
|
+
than any run of backticks inside it. The gap lines are for people; a tool
|
|
137
|
+
should read the JSON, whose `paths` give each site's leaves as
|
|
138
|
+
`line:column` spans, and each possible path with its count:
|
|
139
|
+
|
|
140
|
+
```json
|
|
141
|
+
"paths": {
|
|
142
|
+
"source/kata.rb:3:23": {
|
|
143
|
+
"leaves": [["3:23", "3:30"], ["3:34", "3:42"]],
|
|
144
|
+
"taken": [
|
|
145
|
+
{ "path": [true, true], "count": 2 },
|
|
146
|
+
{ "path": [true, false], "count": 0 },
|
|
147
|
+
{ "path": [false, null], "count": 1 }
|
|
148
|
+
]
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
A site is an outermost condition. Its leaves are numbered left to right
|
|
154
|
+
across the whole of it, nested conditions included:
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
ok?(id) && new?(id) && id.size == 6 # one && site, leaves 0 1 2
|
|
158
|
+
ok?(id) && (new?(id) || id.size == 6) # an || nested in an && site, 0 1 2
|
|
159
|
+
ok?(id) && new?(id) || id.size == 6 # an && nested in an || site, 0 1 2
|
|
160
|
+
ids.any? { |k| k && k.ok? } && ok?(id) # two sites: the block's is its own
|
|
161
|
+
ok?(id) ? :ok : :no # a ?: site, its predicate leaf 0
|
|
162
|
+
ok?(id) && new?(id) ? :ok : :no # an && nested in a ?: site, 0 1
|
|
163
|
+
ok?(id) ? new?(id) && id.size == 6 : :no # one ?: site, its then arm 1 2
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
A ternary is a site even when its predicate is a single leaf. An arm
|
|
167
|
+
holding a condition of `&&`, `||`, `and`, `or` or `?:` is part of the
|
|
168
|
+
ternary's site, its leaves numbered after the predicate's, so the site's
|
|
169
|
+
paths run through whichever arm the predicate picks. Any other arm, like a
|
|
170
|
+
block, is not part of it, though a condition inside it is a site of its
|
|
171
|
+
own.
|
|
172
|
+
|
|
173
|
+
An `if` (with its `end`, not a modifier) is a site too, even when its
|
|
174
|
+
predicate is a single leaf: its predicate nests in it, an `&&` there
|
|
175
|
+
being no site of its own, and its `then` and `else` arms are plain, as
|
|
176
|
+
they are statements. Its extent is the `if` keyword to the end of its
|
|
177
|
+
predicate, so a site inside an arm keeps its own button. An `elsif` is a
|
|
178
|
+
site of its own.
|
|
179
|
+
|
|
180
|
+
A chain of one operator is flat; a change of operator, by precedence or by
|
|
181
|
+
parentheses, nests. Parentheses are not part of the tree, since they decide
|
|
182
|
+
nothing.
|
|
183
|
+
|
|
184
|
+
A leaf whose form allows one outcome only needs only to be evaluated: a
|
|
185
|
+
path needing its other outcome is impossible, not a gap. `Foo.new`,
|
|
186
|
+
literals (`'s'`, `1`, `:k`, `[]`, `{}`, `true`) and lambdas can only be
|
|
187
|
+
truthy, as can `$stdout` and `$stderr`, which Ruby refuses to set to nil
|
|
188
|
+
or false. Other globals, `$stdin` among them, can be either, as can
|
|
189
|
+
constants, since one can hold nil or false (`VERBOSE = false`). `nil` and
|
|
190
|
+
`false` can only be falsy:
|
|
191
|
+
|
|
192
|
+
```ruby
|
|
193
|
+
@clock = options[:clock] || MonotonicClock.new # leaf 1: never evaluated, or no gap
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### Methods
|
|
197
|
+
|
|
198
|
+
With `measure :conditions, :paths`, each method with two or more
|
|
199
|
+
decisions also counts each call's path through them: the outcome of each
|
|
200
|
+
decision it reached, in order. A method is named by the `path:line:column`
|
|
201
|
+
of its `def`, then its name. Each decision is one outcome on the path,
|
|
202
|
+
however many leaves its predicate has; those are its site's to show. So a
|
|
203
|
+
site's columns stay its own leaves' combinations, and a method's its own
|
|
204
|
+
decisions', rather than multiplying together. A decision whose arm always
|
|
205
|
+
leaves the method ends the path there.
|
|
206
|
+
|
|
207
|
+
### Reporters
|
|
208
|
+
|
|
209
|
+
- `:html` writes `index.html` to `coverage_dir`: the covered files with
|
|
210
|
+
their sites, outcomes seen (with a percent) and outcomes missing, each
|
|
211
|
+
column sortable. Each file name opens `files/<path>.html`, the file's
|
|
212
|
+
source. On a line holding a site the line number is a button (blue
|
|
213
|
+
and outlined when every outcome was seen, solid orange when any is
|
|
214
|
+
missing) that opens a grid of the site's paths, on the left. Its rows
|
|
215
|
+
are the site's tree: its operators, nested as Ruby groups them, a
|
|
216
|
+
ternary's arms marked `?` and `:`, an `if`'s named `then` and `else`,
|
|
217
|
+
and its leaves. Its columns are the site's paths, never taken first,
|
|
218
|
+
each headed by its count, orange when zero; a leaf shows its outcome on
|
|
219
|
+
each path, T or F, blank where the path never reached it, and a plain
|
|
220
|
+
arm a dot on each path that took it. A site written over several lines
|
|
221
|
+
has one button over all of its line numbers. Grids with gaps start
|
|
222
|
+
open. A header that stays at the top of the page lists every site as a
|
|
223
|
+
link to its line, covered sites first, then those with gaps, and arrows
|
|
224
|
+
beside each site with gaps jump to the previous and next one, wrapping
|
|
225
|
+
round at either end. Measuring paths, each method's grid of its
|
|
226
|
+
decisions' paths opens on the right.
|
|
227
|
+
- `:post` sends the JSON report, as an HTTP POST, to the url set by
|
|
228
|
+
`collector 'http://...'`. It is for a run whose own files are thrown
|
|
229
|
+
away, such as a failing `docker build` step. It prints nothing, never
|
|
230
|
+
goes through an `http_proxy`, and gives up in silence on a collector
|
|
231
|
+
that cannot be reached or does not answer within a second, so the
|
|
232
|
+
run's output and exit status are unchanged.
|
|
233
|
+
|
|
234
|
+
## How it works
|
|
235
|
+
|
|
236
|
+
At load time each covered file is read and parsed once, with Prism. The
|
|
237
|
+
parse gives the manifest: each site's condition tree and the byte range of
|
|
238
|
+
each of its leaves. The rewriter then inserts a record call around each
|
|
239
|
+
leaf, and a leave call around the whole site, whose first argument enters
|
|
240
|
+
it, without parsing again, and the result is compiled in place of the
|
|
241
|
+
file:
|
|
242
|
+
|
|
243
|
+
```ruby
|
|
244
|
+
ok?(id) && new?(id)
|
|
245
|
+
# compiles as
|
|
246
|
+
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)))
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
An `if` site's leave call wraps its predicate alone, as its path is
|
|
250
|
+
decided there, so an arm that returns or raises still counts it.
|
|
251
|
+
Measuring paths, each method starts a path at its entry, and each
|
|
252
|
+
decision's predicate is wrapped in a decide call too, outside its site's
|
|
253
|
+
calls.
|
|
254
|
+
|
|
255
|
+
A command call such as `ok? id` (after `and`) is not valid as a bare
|
|
256
|
+
argument, so that leaf alone gets parentheses of its own, and so does a
|
|
257
|
+
whole `and` or `or` inside its leave call. Nothing else does:
|
|
258
|
+
parentheses make a statement, and a statement on a continuation line gives
|
|
259
|
+
that line a line-coverage count of its own, which changes SimpleCov's line
|
|
260
|
+
totals. So a command call, or an `and` or `or`, written over several lines
|
|
261
|
+
can add to SimpleCov's relevant lines; nothing else does.
|
|
262
|
+
|
|
263
|
+
`record` counts whether its value was truthy or falsy, and notes it on the
|
|
264
|
+
site's current path; `enter` starts that path, and `leave` counts it.
|
|
265
|
+
Each returns its value unchanged, so short-circuiting and the value of
|
|
266
|
+
the expression are preserved. Paths are kept per fiber, so threads never
|
|
267
|
+
mix their outcomes; an evaluation cut short by a raise counts no path,
|
|
268
|
+
so no path is ever claimed that was not taken. Only text is inserted,
|
|
269
|
+
never a newline, so backtrace line numbers still match the file. The
|
|
270
|
+
rewrite happens in memory; no file on disk changes.
|
|
271
|
+
|
|
272
|
+
At exit the manifests parsed during the run are reused; only covered files
|
|
273
|
+
the run never loaded are read and parsed then.
|
|
274
|
+
|
|
275
|
+
## Roadmap
|
|
276
|
+
|
|
277
|
+
- decisions hidden inside calls, each with its own outcome classes:
|
|
278
|
+
- `any?` / `all?` / `none?`: empty, decided by the first item, decided
|
|
279
|
+
later, never decided
|
|
280
|
+
- `find` / `detect`: found vs nil
|
|
281
|
+
- `fetch(k, default)`, `dig`, `Hash#[]` with a default: present vs default
|
|
282
|
+
- `x || default` in value context, `&.` safe navigation: nil vs not nil
|
|
283
|
+
- `||=` and `&&=`, on variables, attributes and index targets: the
|
|
284
|
+
right side evaluated vs skipped, counted without evaluating the
|
|
285
|
+
target twice
|
|
286
|
+
- `x rescue y` modifier: raised vs not raised
|
|
287
|
+
- `unless`, modifier `if`, `case`/`when` and `in` patterns
|
|
288
|
+
- regex matches: nil vs MatchData
|
|
289
|
+
|
|
290
|
+
## Constraints
|
|
291
|
+
|
|
292
|
+
- CRuby only: the hook is `RubyVM::InstructionSequence.load_iseq`.
|
|
293
|
+
- Ruby 3.2 or later, for `String#bytesplice`.
|
|
294
|
+
- That hook has a single slot, which Bootsnap also uses, so the two cannot
|
|
295
|
+
both be active in one process.
|
|
296
|
+
|
|
297
|
+
## Development
|
|
298
|
+
|
|
299
|
+
`make all` runs, in a Docker image built from the repo's `Dockerfile`:
|
|
300
|
+
|
|
301
|
+
- `make lint`: rubocop over `lib/`, `test/` and `bin/` alike
|
|
302
|
+
- `make test`: the tests, then a check that the coverage report has zero
|
|
303
|
+
missed lines and branches in both the code and the tests, within the
|
|
304
|
+
bounds in `test/coverage_metrics_params.json`
|
|
305
|
+
(`make test tids='6D1K'` runs only matching tests, and skips the check)
|
|
306
|
+
- `make test_check`: shunit2 tests showing that a breached bound fails the
|
|
307
|
+
check with a non-zero exit
|
|
308
|
+
|
|
309
|
+
`make demo` (not part of `make all`) runs `demo/run.rb` through
|
|
310
|
+
`bin/build_demo.sh`, writing the report to `site/` and opening it. The
|
|
311
|
+
Pages workflow (`.github/workflows/pages.yml`) runs the same script on
|
|
312
|
+
each push to `main` and publishes `site/`.
|
|
313
|
+
|
|
314
|
+
`bin/capture_readme_images.sh` rebuilds the demo and saves this README's
|
|
315
|
+
two screenshots, `docs/decision.png` and `docs/method.png`, from it with
|
|
316
|
+
headless Chrome. Run it again whenever the report's look changes.
|
|
@@ -0,0 +1,26 @@
|
|
|
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. Runs until stopped.
|
|
13
|
+
|
|
14
|
+
Options:
|
|
15
|
+
-h Show this help
|
|
16
|
+
|
|
17
|
+
Example:
|
|
18
|
+
path_coverage_collector 4570 /tmp/condition/runs
|
|
19
|
+
HELP
|
|
20
|
+
exit 0
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
port, dir = ARGV
|
|
24
|
+
FileUtils.mkdir_p(dir)
|
|
25
|
+
server = TCPServer.new('0.0.0.0', Integer(port))
|
|
26
|
+
PathCoverage::Collector.serve(server, dir)
|
|
@@ -0,0 +1,46 @@
|
|
|
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, forever, saving each
|
|
7
|
+
# body in dir as run-0001.json, run-0002.json, and so on,
|
|
8
|
+
# in the order they arrive. A connection sending no
|
|
9
|
+
# request, eg a check that the collector is up, is skipped.
|
|
10
|
+
def self.serve(server, dir)
|
|
11
|
+
n = 0
|
|
12
|
+
loop do
|
|
13
|
+
body = request_body(server.accept)
|
|
14
|
+
next if body.nil?
|
|
15
|
+
|
|
16
|
+
n += 1
|
|
17
|
+
File.write(format('%<dir>s/run-%<n>04d.json', dir:, n:), body)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The body of the one HTTP request on client, after
|
|
22
|
+
# answering it 200; nil, unanswered, when client closes
|
|
23
|
+
# without sending a request.
|
|
24
|
+
def self.request_body(client)
|
|
25
|
+
headers = []
|
|
26
|
+
while (line = client.gets) && line != "\r\n"
|
|
27
|
+
headers << line
|
|
28
|
+
end
|
|
29
|
+
body = headers.empty? ? nil : answer(client, headers)
|
|
30
|
+
client.close
|
|
31
|
+
body
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The body of a request with headers, read from client,
|
|
35
|
+
# which is then answered 200.
|
|
36
|
+
def self.answer(client, headers)
|
|
37
|
+
length = headers.sum do |line|
|
|
38
|
+
name, value = line.split(':', 2)
|
|
39
|
+
name.casecmp?('content-length') ? value.to_i : 0
|
|
40
|
+
end
|
|
41
|
+
body = client.read(length)
|
|
42
|
+
client.write("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
|
|
43
|
+
body
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module PathCoverage
|
|
2
|
+
# The operators whose predicate picks an arm.
|
|
3
|
+
CHOICES = %w[?: if].freeze
|
|
4
|
+
|
|
5
|
+
# One arm of a ternary or an if: kind is :then or :else,
|
|
6
|
+
# extent its byte range, nil for an if's arm with no
|
|
7
|
+
# statements, and body the Condition it is, when it is one,
|
|
8
|
+
# else nil for a plain value or statements, which decide
|
|
9
|
+
# nothing.
|
|
10
|
+
Arm = Data.define(:kind, :extent, :body)
|
|
11
|
+
|
|
12
|
+
# One site's condition as the manifest found it: its operator
|
|
13
|
+
# and its operands, each the byte range of a leaf or a nested
|
|
14
|
+
# Condition. A site's commands are the byte ranges of its
|
|
15
|
+
# leaves that are command calls, eg `ok? id`; its fixed are
|
|
16
|
+
# { byte range => :truthy } for leaves that can have that one
|
|
17
|
+
# outcome only, eg `Clock.new`. Its extent is the byte range
|
|
18
|
+
# of the whole condition as written, eg a ternary's from its
|
|
19
|
+
# predicate to its else arm, which its leaves alone cannot
|
|
20
|
+
# give.
|
|
21
|
+
Condition = Data.define(
|
|
22
|
+
:operator, :operands, :commands, :fixed, :extent
|
|
23
|
+
) do
|
|
24
|
+
# A condition with no command-call or fixed leaves, and no
|
|
25
|
+
# extent, by default.
|
|
26
|
+
def initialize(
|
|
27
|
+
operator:, operands:, commands: [], fixed: {}, extent: nil
|
|
28
|
+
)
|
|
29
|
+
super
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The byte range of every leaf operand, left to right, a
|
|
33
|
+
# ternary's arms' after its predicate's; a leaf's index here
|
|
34
|
+
# is the index it is recorded under.
|
|
35
|
+
def leaves
|
|
36
|
+
operands.flat_map do |operand|
|
|
37
|
+
case operand
|
|
38
|
+
when Condition then operand.leaves
|
|
39
|
+
when Arm then operand.body&.leaves || []
|
|
40
|
+
else [operand]
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
module PathCoverage
|
|
2
|
+
# Raised for a start block that cannot be applied.
|
|
3
|
+
class ConfigurationError < StandardError; end
|
|
4
|
+
|
|
5
|
+
# The settings given in a PathCoverage.start block.
|
|
6
|
+
class Configuration
|
|
7
|
+
GLOB_FLAGS = File::FNM_PATHNAME | File::FNM_EXTGLOB
|
|
8
|
+
SETTINGS = %w[
|
|
9
|
+
collector cover coverage_dir measure report root
|
|
10
|
+
].freeze
|
|
11
|
+
REPORTERS = %w[gaps html post].freeze
|
|
12
|
+
MEASURES = %w[conditions paths].freeze
|
|
13
|
+
|
|
14
|
+
# Evaluates the settings block against a new configuration.
|
|
15
|
+
def initialize(&)
|
|
16
|
+
@globs = []
|
|
17
|
+
@reporters = [:gaps]
|
|
18
|
+
@measures = [:conditions]
|
|
19
|
+
instance_eval(&)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The reporters chosen with report, :gaps when none were.
|
|
23
|
+
attr_reader :reporters
|
|
24
|
+
|
|
25
|
+
# What is measured, chosen with measure, :conditions when
|
|
26
|
+
# nothing was.
|
|
27
|
+
attr_reader :measures
|
|
28
|
+
|
|
29
|
+
# Chooses what is measured, eg measure :conditions, :paths;
|
|
30
|
+
# refuses a name there is no measure for.
|
|
31
|
+
def measure(*names)
|
|
32
|
+
check('measure', names, MEASURES)
|
|
33
|
+
@measures = names.map(&:to_sym)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The dir the reports are written to.
|
|
37
|
+
def output_dir
|
|
38
|
+
@coverage_dir
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The url the :post reporter sends the JSON report to.
|
|
42
|
+
attr_reader :collector_url
|
|
43
|
+
|
|
44
|
+
# Sets the url the :post reporter sends the JSON report to.
|
|
45
|
+
def collector(url)
|
|
46
|
+
@collector_url = url
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Sets the dir that cover globs and site names are
|
|
50
|
+
# relative to.
|
|
51
|
+
def root(dir)
|
|
52
|
+
@root = dir
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Adds a glob, relative to root, of files to instrument.
|
|
56
|
+
def cover(glob)
|
|
57
|
+
@globs << glob
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Chooses the reporters run at exit, eg report :gaps;
|
|
61
|
+
# refuses a name there is no reporter for.
|
|
62
|
+
def report(*names)
|
|
63
|
+
check('reporter', names, REPORTERS)
|
|
64
|
+
@reporters = names.map(&:to_sym)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Refuses the first of names that is not in known, naming
|
|
68
|
+
# it as a kind and listing the known ones.
|
|
69
|
+
def check(kind, names, known)
|
|
70
|
+
names.each do |name|
|
|
71
|
+
next if known.include?(name.to_s)
|
|
72
|
+
|
|
73
|
+
raise ConfigurationError, [
|
|
74
|
+
"unknown #{kind} #{name} in PathCoverage.start",
|
|
75
|
+
"the #{kind}s are: #{known.join(', ')}"
|
|
76
|
+
].join("\n")
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Sets the dir the path_coverage.json report is
|
|
81
|
+
# written to.
|
|
82
|
+
def coverage_dir(dir)
|
|
83
|
+
@coverage_dir = dir
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Every file the cover globs match under root, as full
|
|
87
|
+
# paths, whether or not the run loads it.
|
|
88
|
+
def files
|
|
89
|
+
Dir.glob(@globs, base: @root).sort.map { |f| "#{@root}/#{f}" }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Where the JSON report goes.
|
|
93
|
+
def json_path
|
|
94
|
+
"#{output_dir}/path_coverage.json"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# True when path is under root and matches a cover glob.
|
|
98
|
+
def covers?(path)
|
|
99
|
+
path.start_with?("#{@root}/") && matches?(relative(path))
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# True when the root-relative path matches a cover glob.
|
|
103
|
+
def matches?(relative_path)
|
|
104
|
+
@globs.any? do |glob|
|
|
105
|
+
File.fnmatch?(glob, relative_path, GLOB_FLAGS)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# path relative to root, eg "source/kata.rb".
|
|
110
|
+
def relative(path)
|
|
111
|
+
path.delete_prefix("#{@root}/")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Refuses a setting that does not exist, naming it.
|
|
115
|
+
def method_missing(name, *)
|
|
116
|
+
raise ConfigurationError, [
|
|
117
|
+
"unknown setting #{name} in PathCoverage.start",
|
|
118
|
+
"the settings are: #{SETTINGS.join(', ')}"
|
|
119
|
+
].join("\n")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Answers only for the settings there are.
|
|
123
|
+
def respond_to_missing?(_name, _include_private = false)
|
|
124
|
+
false
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
module PathCoverage
|
|
2
|
+
# The terminal worklist: one line per path a site never
|
|
3
|
+
# took, and nothing for a site that took them all. Every
|
|
4
|
+
# count stays in the operands and paths for reporters that
|
|
5
|
+
# show them all.
|
|
6
|
+
module Gaps
|
|
7
|
+
# The gap lines of operands and paths, as Result gives
|
|
8
|
+
# them, in manifest order; texts is { site => each leaf's
|
|
9
|
+
# source text }.
|
|
10
|
+
def self.lines(operands, paths: {}, texts: {})
|
|
11
|
+
operands.flat_map do |site, counts|
|
|
12
|
+
if counts.all? { |each| never_ran?(each) }
|
|
13
|
+
next ["#{site} never reached"]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
path_gaps(site, paths.fetch(site, []), texts[site])
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# One line per path site never took, as Result#paths gives
|
|
21
|
+
# them, naming the outcome of each leaf the path reaches,
|
|
22
|
+
# texts being each leaf's source text.
|
|
23
|
+
def self.path_gaps(site, paths, texts)
|
|
24
|
+
paths.select { |each| each[:count].zero? }.map do |each|
|
|
25
|
+
steps = each[:path].zip(texts).filter_map do |outcome, text|
|
|
26
|
+
"#{code(text)} #{outcome}" unless outcome.nil?
|
|
27
|
+
end
|
|
28
|
+
"#{site} path never taken: #{steps.join(', ')}"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# text as a markdown code span on one line: each run of
|
|
33
|
+
# whitespace one space, fenced by a run of backticks one
|
|
34
|
+
# longer than any inside it, with a space inside the fence
|
|
35
|
+
# when text starts or ends with one.
|
|
36
|
+
def self.code(text)
|
|
37
|
+
text = text.gsub(/\s+/, ' ')
|
|
38
|
+
longest = text.scan(/`+/).map(&:size).max || 0
|
|
39
|
+
fence = '`' * (longest + 1)
|
|
40
|
+
pad = text.start_with?('`') || text.end_with?('`') ? ' ' : ''
|
|
41
|
+
"#{fence}#{pad}#{text}#{pad}#{fence}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The one outcome this operand is missing, if any; a fixed
|
|
45
|
+
# leaf, which can have one outcome only, needs only to run.
|
|
46
|
+
def self.operand_gap(counts)
|
|
47
|
+
return ['never evaluated'] if never_ran?(counts)
|
|
48
|
+
return [] if counts[:only]
|
|
49
|
+
return ['never true'] if counts[:truthy].zero?
|
|
50
|
+
return ['never false'] if counts[:falsy].zero?
|
|
51
|
+
|
|
52
|
+
[]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# True when the operand was never recorded either way.
|
|
56
|
+
def self.never_ran?(counts)
|
|
57
|
+
counts[:truthy].zero? && counts[:falsy].zero?
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|