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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.md +22 -0
  3. data/README.md +353 -0
  4. data/exe/path_coverage_collector +37 -0
  5. data/lib/path_coverage/auto.rb +5 -0
  6. data/lib/path_coverage/calls.rb +67 -0
  7. data/lib/path_coverage/collector.rb +112 -0
  8. data/lib/path_coverage/condition.rb +96 -0
  9. data/lib/path_coverage/configuration.rb +130 -0
  10. data/lib/path_coverage/gaps.rb +60 -0
  11. data/lib/path_coverage/html/file.html +33 -0
  12. data/lib/path_coverage/html/index.html +41 -0
  13. data/lib/path_coverage/html/side.js +542 -0
  14. data/lib/path_coverage/html/sort.js +31 -0
  15. data/lib/path_coverage/html/style.css +338 -0
  16. data/lib/path_coverage/html/toggle.js +12 -0
  17. data/lib/path_coverage/html_file.rb +109 -0
  18. data/lib/path_coverage/html_index.rb +74 -0
  19. data/lib/path_coverage/html_nav.rb +67 -0
  20. data/lib/path_coverage/html_paths.rb +138 -0
  21. data/lib/path_coverage/html_report.rb +118 -0
  22. data/lib/path_coverage/html_template.rb +20 -0
  23. data/lib/path_coverage/html_tree.rb +146 -0
  24. data/lib/path_coverage/json_writer.rb +25 -0
  25. data/lib/path_coverage/kernel.rb +165 -0
  26. data/lib/path_coverage/manifest.rb +205 -0
  27. data/lib/path_coverage/manifest_blocks.rb +57 -0
  28. data/lib/path_coverage/manifest_case.rb +54 -0
  29. data/lib/path_coverage/manifest_choices.rb +132 -0
  30. data/lib/path_coverage/merge.rb +35 -0
  31. data/lib/path_coverage/path_gaps.rb +41 -0
  32. data/lib/path_coverage/path_manifest.rb +110 -0
  33. data/lib/path_coverage/paths_report.rb +55 -0
  34. data/lib/path_coverage/post.rb +26 -0
  35. data/lib/path_coverage/recorder.rb +182 -0
  36. data/lib/path_coverage/render.rb +92 -0
  37. data/lib/path_coverage/result.rb +59 -0
  38. data/lib/path_coverage/rewriter.rb +166 -0
  39. data/lib/path_coverage/site.rb +16 -0
  40. data/lib/path_coverage/site_paths.rb +86 -0
  41. data/lib/path_coverage/site_tree.rb +134 -0
  42. data/lib/path_coverage/source_lines.rb +79 -0
  43. data/lib/path_coverage/summary.rb +52 -0
  44. data/lib/path_coverage/totals.rb +29 -0
  45. data/lib/path_coverage/version.rb +3 -0
  46. data/lib/path_coverage.rb +2 -5
  47. metadata +73 -14
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dc1af74670e1171318d1d58fbd6a27f19b89c01ac95ee556f4e6bac3afa2bfbf
4
- data.tar.gz: 2ecf0d75608a054914d31e426c77f75cc607ac578671a8e93fe4494efd040ea2
3
+ metadata.gz: 7146cc322217826aadc2bd040b560b5e77d6fad7b6de4d2ae43e8bdfaa893940
4
+ data.tar.gz: 29135bab12a1cc92d1a15dd8d383d3e79d75b3b8716fff7efa95fbbb6f8c3b34
5
5
  SHA512:
6
- metadata.gz: 41b26c437c12f9b6aa7ab78d3a4a50a2fd532d2a8db0923813a059943fc0289d3421a78e754c633db0ea20962e42564031b98453c2e2ad6cb6d61cef81557ac8
7
- data.tar.gz: 52b79781e54c09a07d1b6733ef3356ba8aac5cc87881fdb0303aae99842d2ef48cf00e555e29f11215fc8c1516986bf2f8bff02fdb5d539da3f48c0e688d1c79
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
+ [![Gem Version](https://img.shields.io/gem/v/path_coverage)](https://rubygems.org/gems/path_coverage)
4
+ [![Downloads](https://img.shields.io/gem/dt/path_coverage)](https://rubygems.org/gems/path_coverage)
5
+ [![License](https://img.shields.io/github/license/cyber-dojo/path_coverage)](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
+ ![The first if's grid: a column per way it can go, each headed by
51
+ its count, the member who has not paid at 0](docs/decision.png)
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
+ ![fee's grid: a column per way its two ifs can go together, each
59
+ headed by its count, true then false at 0 and selected](docs/method.png)
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,5 @@
1
+ require_relative 'kernel'
2
+
3
+ # Required by RUBYOPT=-rpath_coverage/auto, to cover a run
4
+ # with no change to its code, its settings all in ENV.
5
+ PathCoverage.start_from_env(ENV)
@@ -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